diff --git a/applications/Unity.AutoUI/cypress.config.ts b/applications/Unity.AutoUI/cypress.config.ts index c227e4bc3d..2d846e5c58 100644 --- a/applications/Unity.AutoUI/cypress.config.ts +++ b/applications/Unity.AutoUI/cypress.config.ts @@ -1,22 +1,72 @@ -import { defineConfig } from 'cypress'; +import { defineConfig } from "cypress"; +import FormData from "form-data"; +import fs from "fs"; +import path from "path"; + // https://docs.cypress.io/guides/references/configuration export default defineConfig({ e2e: { - setupNodeEvents(on, config) { - // implement node event listeners here + setupNodeEvents(on) { + on("task", { + async uploadChefsFile({ + baseURL, + authToken, + filePath, + }: { + baseURL: string; + authToken: string; + filePath: string; + }) { + const fileBuffer = fs.readFileSync(filePath); + const fileName = path.basename(filePath); + + const form = new FormData(); + form.append("files", fileBuffer, { + filename: fileName, + contentType: "text/plain", + }); + + // Use getBuffer() so fetch receives a complete binary buffer + // rather than a piped stream (which causes "Unexpected end of form") + const formBuffer = form.getBuffer(); + const formHeaders = form.getHeaders(); + + const response = await fetch(`${baseURL}/app/api/v1/files`, { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + ...formHeaders, + }, + body: formBuffer as unknown as BodyInit, + }); + + if (!response.ok) { + throw new Error( + `File upload failed: ${response.status} ${await response.text()}`, + ); + } + + return response.json(); + }, + }); }, - baseUrl: 'https://developer.gov.bc.ca/', + specPattern: [ + "cypress/e2e/**/*.cy.{js,jsx,ts,tsx}", + "cypress/scripts/**/*.cy.{js,jsx,ts,tsx}", + "cypress/regression/**/*.cy.{js,jsx,ts,tsx}", + ], + baseUrl: "https://dev-unity.apps.silver.devops.gov.bc.ca/", defaultCommandTimeout: 20000, // Time, in milliseconds, to wait until most DOM based commands are considered timed out. - viewportWidth: 1440, // Default width in pixels. - viewportHeight: 900, // Default height in pixels. + viewportWidth: 1440, // Default width in pixels. + viewportHeight: 900, // Default height in pixels. chromeWebSecurity: false, // Chromium-based browser's Web Security for same-origin policy and insecure mixed content. - testIsolation: false, // Set true to ensure a clean browser context between test cases. - retries: // The number of times to retry a failing test. - { - "runMode": 3, - "openMode": 0 - }, + testIsolation: false, // Set true to ensure a clean browser context between test cases. + // The number of times to retry a failing test. + retries: { + runMode: 3, + openMode: 0, + }, experimentalMemoryManagement: true, - numTestsKeptInMemory: 3 - } -}); \ No newline at end of file + numTestsKeptInMemory: 3, + }, +}); diff --git a/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts b/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts index 0c08e11ce6..6ae85878e2 100644 --- a/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts @@ -306,20 +306,23 @@ describe("Send an email", () => { }); it("Select Email Template", () => { - cy.intercept("GET", "/api/app/template/*/template-by-id").as( - "loadTemplate", - ); - cy.get("#template", { timeout: STANDARD_TIMEOUT }) .should("exist") .should("be.visible") .select(TEMPLATE_NAME); - cy.wait("@loadTemplate", { timeout: STANDARD_TIMEOUT }); - cy.get("#template") .find("option:selected") .should("have.text", TEMPLATE_NAME); + + // #EmailBody is a hidden textarea backing the rich-text editor. + // Template selection populates the visible RTE but does not auto-sync + // the backing field — trigger the change manually if still empty. + cy.get("#EmailBody", { timeout: STANDARD_TIMEOUT }).then(($el) => { + if (($el.val() as string).trim() === "") { + cy.wrap($el).invoke("val", "Test email body").trigger("change"); + } + }); }); it("Set Email To address", () => { diff --git a/applications/Unity.AutoUI/cypress/fixtures/test-attachment.txt b/applications/Unity.AutoUI/cypress/fixtures/test-attachment.txt new file mode 100644 index 0000000000..883ad3eacc --- /dev/null +++ b/applications/Unity.AutoUI/cypress/fixtures/test-attachment.txt @@ -0,0 +1,3 @@ +Maple Ridge Community Resource Development Initiative +Test Attachment - Automated Regression Submission +Generated by Cypress automation script. diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts index 6194b02f1f..22f0f2eee6 100644 --- a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts @@ -34,6 +34,13 @@ export class ApplicationDetailsPage extends BasePage { onHold: "#Application_OnHoldButton", }; + // Confirm action modal selectors (SweetAlert2) + private readonly confirmModal = { + modal: ".swal2-popup", + confirmButton: "button.swal2-confirm", + cancelButton: "button.swal2-cancel", + }; + // Field selectors for Summary/Info Panel private readonly summaryFields = { category: "Category", @@ -106,55 +113,63 @@ export class ApplicationDetailsPage extends BasePage { /** * Navigate to Submission tab */ - goToSubmissionTab(): void { + goToSubmissionTab(): this { this.clickElement(this.tabs.submission); + return this; } /** * Navigate to Review & Assessment tab */ - goToReviewAssessmentTab(): void { + goToReviewAssessmentTab(): this { + this.dismissErrorModalIfPresent(); this.clickElement(this.tabs.reviewAssessment); + return this; } /** * Navigate to Project Info tab */ - goToProjectInfoTab(): void { + goToProjectInfoTab(): this { this.clickElement(this.tabs.projectInfo); + return this; } /** * Navigate to Applicant Info tab */ - goToApplicantInfoTab(): void { + goToApplicantInfoTab(): this { this.clickElement(this.tabs.applicantInfo); + return this; } /** * Navigate to Funding Agreement tab */ - goToFundingAgreementTab(): void { + goToFundingAgreementTab(): this { this.clickElement(this.tabs.fundingAgreement); + return this; } /** * Navigate to Payment Info tab */ - goToPaymentInfoTab(): void { + goToPaymentInfoTab(): this { this.clickElement(this.tabs.paymentInfo); + return this; } /** * Verify all tabs are visible */ - verifyAllTabsVisible(): void { + verifyAllTabsVisible(): this { cy.get(this.tabs.submission).should("be.visible"); cy.get(this.tabs.reviewAssessment).should("be.visible"); cy.get(this.tabs.projectInfo).should("be.visible"); cy.get(this.tabs.applicantInfo).should("be.visible"); cy.get(this.tabs.fundingAgreement).should("be.visible"); cy.get(this.tabs.paymentInfo).should("be.visible"); + return this; } /** @@ -168,7 +183,7 @@ export class ApplicationDetailsPage extends BasePage { | "applicantInfo" | "fundingAgreement" | "paymentInfo" - ): void { + ): this { const tabSelectors: Record = { submission: this.tabs.submission, reviewAssessment: this.tabs.reviewAssessment, @@ -178,6 +193,7 @@ export class ApplicationDetailsPage extends BasePage { paymentInfo: this.tabs.paymentInfo, }; cy.get(tabSelectors[tabName]).should("have.class", "active"); + return this; } /** @@ -294,14 +310,132 @@ export class ApplicationDetailsPage extends BasePage { this.verifyInputValue("#TotalBudgetInputAR", budget); } + // ============ Payment Info Methods ============ + + /** + * Enter Supplier Number + */ + enterSupplierNumber(supplierNumber: string): this { + cy.get("#SupplierNumber", { timeout: 20000 }) + .clear({ force: true }) + .type(supplierNumber, { force: true }) + .trigger("change") + .blur(); + return this; + } + + /** + * Click elsewhere to trigger save button enable + */ + clickElsewhere(): this { + cy.get("body").click(0, 0); + return this; + } + + /** + * Click Payment Info Save button + */ + clickPaymentInfoSave(): this { + cy.get("#nav-payment-info", { timeout: 20000 }) + .contains("button", "Save") + .click({ force: true }); + // Wait briefly for save to process + cy.wait(1000); + return this; + } + + /** + * Verify Site Info table is populated + */ + verifySiteInfoTablePopulated(): this { + cy.get("#SiteInfoTable tbody tr", { timeout: 20000 }) + .should("have.length.at.least", 1); + return this; + } + + /** + * Verify Site Info table has data in specific columns + */ + verifySiteInfoTableHasData(): this { + cy.get("#SiteInfoTable tbody tr", { timeout: 20000 }).first().within(() => { + cy.get("td").eq(0).should("not.be.empty"); // Site # + cy.get("td").eq(1).should("not.be.empty"); // Pay Group + cy.get("td").eq(2).should("not.be.empty"); // Mailing Address + }); + return this; + } + + /** + * Click Edit button in Site Info table + */ + clickSiteInfoEdit(): this { + cy.get("#SiteInfoTable tbody tr", { timeout: 20000 }) + .first() + .find("button, a") + .filter(':contains("Edit"), [title="Edit"], .edit-btn, .btn-edit') + .first() + .click({ force: true }); + return this; + } + + /** + * Wait for Edit Site modal to appear + */ + waitForEditSiteModal(): this { + cy.get(".modal-content", { timeout: 20000 }) + .contains(".modal-title", "Edit Site") + .should("be.visible"); + return this; + } + + /** + * Select Payment Group in Edit Site modal + */ + selectPaymentGroup(paymentGroup: "EFT" | "Cheque"): this { + cy.get("#Site_PaymentGroup", { timeout: 20000 }) + .select(paymentGroup, { force: true }); + return this; + } + + /** + * Click Save Changes in Edit Site modal + */ + clickSaveChanges(): this { + cy.get(".modal-footer", { timeout: 20000 }) + .contains("button", "SAVE CHANGES") + .click({ force: true }); + cy.wait(2000); // Wait for save to process + cy.get("body").type("{esc}"); + cy.get(".modal.show, .modal.fade.show", { timeout: 20000 }).should("not.exist"); + cy.get(".modal-backdrop", { timeout: 20000 }).should("not.exist"); + return this; + } + + /** + * Click Cancel in Edit Site modal + */ + clickModalCancel(): this { + cy.get(".modal-footer", { timeout: 20000 }) + .contains("button", "CANCEL") + .click({ force: true }); + return this; + } + // ============ Status Actions Dropdown Methods ============ /** * Open the Status Actions dropdown */ openStatusActionsDropdown(): void { - this.clickElement(this.statusActions.dropdownToggle); - cy.get(this.statusActions.dropdownMenu).should("be.visible"); + cy.get(this.statusActions.dropdownMenu).then(($menu) => { + if (!$menu.is(":visible")) { + cy.get(this.statusActions.dropdownToggle, { timeout: 20000 }) + .should("exist") + .scrollIntoView() + .click({ force: true }); + } + }); + cy.get(this.statusActions.dropdownMenu, { timeout: 10000 }).should("be.visible"); } /** @@ -337,11 +471,33 @@ export class ApplicationDetailsPage extends BasePage { } /** - * Click Approve action + * Click Approve action. + * If "Complete Assessment" is enabled in the dropdown, click it first, + * then reopen the dropdown before clicking Approve. */ - clickApprove(): void { + clickApprove(): this { + this.openStatusActionsDropdown(); + cy.get(this.statusActions.completeAssessment).then(($btn) => { + if (!$btn.is(":disabled")) { + cy.wrap($btn).click({ force: true }); + cy.get("body").then(($body) => { + if ($body.find(this.confirmModal.modal).filter(":visible").length > 0) { + cy.get(this.confirmModal.modal) + .find(this.confirmModal.confirmButton) + .click({ force: true }); + } + }); + // Wait for page to stabilize after status transition + cy.get(this.statusActions.dropdownToggle, { timeout: 20000 }).should("be.visible"); + cy.wait(2000); + } + }); + // Always reopen dropdown fresh before clicking Approve (dropdown may have closed) this.openStatusActionsDropdown(); - this.clickElement(this.statusActions.approve); + cy.get(this.statusActions.approve, { timeout: 10000 }) + .should("exist") + .click({ force: true }); + return this; } /** @@ -384,6 +540,56 @@ export class ApplicationDetailsPage extends BasePage { this.clickElement(this.statusActions.onHold); } + // ============ Confirm Modal Methods ============ + + /** + * Wait for confirm action modal to appear (SweetAlert2) + */ + waitForConfirmModal(): this { + cy.get(this.confirmModal.modal, { timeout: 20000 }).should("be.visible"); + return this; + } + + /** + * Click Confirm button in the modal (SweetAlert2) + */ + clickConfirm(): this { + cy.get(this.confirmModal.modal, { timeout: 20000 }) + .find(this.confirmModal.confirmButton) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Click Cancel button in the modal (SweetAlert2) + */ + clickCancel(): this { + cy.get(this.confirmModal.modal, { timeout: 20000 }) + .find(this.confirmModal.cancelButton) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Dismiss any error modal if present (SweetAlert2) + * Uses failOnStatusCode: false to not fail if no modal exists + */ + dismissErrorModalIfPresent(): this { + cy.get("body").then(($body) => { + // Only dismiss if it is specifically an error modal (swal2-error icon) + if ($body.find(".swal2-container .swal2-icon.swal2-error").length > 0) { + cy.get(".swal2-container") + .find(".swal2-confirm") + .first() + .click({ force: true }); + cy.wait(500); + } + }); + return this; + } + /** * Verify status action is enabled */ diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsRightTabPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsRightTabPage.ts new file mode 100644 index 0000000000..1115fb4967 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsRightTabPage.ts @@ -0,0 +1,644 @@ +/// + +import { BasePage } from "./BasePage"; + +/** + * ApplicationDetailsRightTabPage - Page Object for the right panel tabs on Application Details + * Handles: Details, Emails, Comments, Attachments, Links, History tabs + */ +export class ApplicationDetailsRightTabPage extends BasePage { + private readonly STANDARD_TIMEOUT = 20000; + + // Right panel container + private readonly container = ".right-card"; + + // Tab button selectors + private readonly tabs = { + details: "#details-tab", + emails: "#emails-tab", + comments: "#comments-tab", + attachments: "#attachments-tab", + links: "#links-tab", + history: "#history-tab", + }; + + // Tab content selectors + private readonly tabContent = { + details: "#details", + emails: "#emails", + comments: "#comments", + attachments: "#attachments", + links: "#links", + history: "#history", + }; + + // Count badge selectors + private readonly countBadges = { + emails: "#application_emails_count", + comments: "#application_comments_count", + attachments: "#application_attachment_count", + links: "#application_links_count", + }; + + // Details tab selectors + private readonly detailsSection = { + applicationStatusWidget: "#applicationStatusWidget", + applicationTagsWidget: "#applicationTagsWidget", + summaryWidgetArea: "#summaryWidgetArea", + summaryTable: ".summary-table", + }; + + // Assessment section selectors (in Details tab) + private readonly assessmentSection = { + reviewDetails: "#reviewDetails", + assessmentId: "#AssessmentId", + financialAnalysis: "#financialAnalysis", + economicImpact: "#economicImpact", + inclusiveGrowth: "#inclusiveGrowth", + cleanGrowth: "#cleanGrowth", + subTotal: "#subTotal", + saveAssessmentScoresBtn: "#saveAssessmentScoresBtn", + recommendationSelect: "#recommendation_select", + recommendationResetBtn: "#recommendation_reset_btn", + }; + + // Email section selectors + private readonly emailSection = { + newEmailBtn: "#btn-new-email", + emailForm: "#EmailForm", + templateSelect: "#template", + emailTo: "#EmailTo", + emailCC: "#EmailCC", + emailBCC: "#EmailBCC", + emailFrom: "#EmailFrom", + emailSubject: "#EmailSubject", + emailBody: "#EmailBody", + saveBtn: "#btn-save", + sendBtn: "#btn-send", + cancelBtn: "#btn-cancel-email", + confirmSendBtn: "#btn-confirm-send", + }; + + // Comments section selectors + private readonly commentsSection = { + commentTextArea: "#comments .comment-input", + addCommentSaveBtn: "#comments .add-comment-save-button", + addCommentCancelBtn: "#comments .add-comment-cancel-button", + commentsContainer: "#comments .comments-container", + }; + + // Attachments section selectors + private readonly attachmentsSection = { + attachmentsTable: "#ApplicationAttachmentsTable", + submissionAttachmentsTable: ".submission-attachments-table, [id*='SubmissionAttachments']", + uploadBtn: "#application_upload_btn", + uploadInput: "#application_upload", + addAttachmentsBtn: "button:contains('Add Attachments'), .add-attachments-btn, [id*='addAttachment']", + }; + + // Links section selectors + private readonly linksSection = { + linksTable: "#ApplicationLinksTable", + addLinkBtn: "#addLinkBtn", + }; + + // History section selectors + private readonly historySection = { + historyTable: "#ApplicationHistoryTable", + }; + + constructor() { + super(); + } + + // ============ Tab Navigation Methods ============ + + /** + * Go to Details tab + */ + goToDetailsTab(): this { + cy.get(this.tabs.details, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Go to Emails tab + */ + goToEmailsTab(): this { + cy.get(this.tabs.emails, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Go to Comments tab + */ + goToCommentsTab(): this { + cy.get(this.tabs.comments, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Go to Attachments tab + */ + goToAttachmentsTab(): this { + cy.get(this.tabs.attachments, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Go to Links tab + */ + goToLinksTab(): this { + cy.get(this.tabs.links, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Go to History tab + */ + goToHistoryTab(): this { + cy.get(this.tabs.history, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Verify active tab + */ + verifyActiveTab( + tabName: "details" | "emails" | "comments" | "attachments" | "links" | "history" + ): this { + cy.get(this.tabs[tabName], { timeout: this.STANDARD_TIMEOUT }) + .should("have.class", "active"); + return this; + } + + // ============ Count Badge Methods ============ + + /** + * Get emails count + */ + getEmailsCount(): Cypress.Chainable { + return cy + .get(this.countBadges.emails, { timeout: this.STANDARD_TIMEOUT }) + .invoke("text") + .then((text) => parseInt(text, 10) || 0); + } + + /** + * Get comments count + */ + getCommentsCount(): Cypress.Chainable { + return cy + .get(this.countBadges.comments, { timeout: this.STANDARD_TIMEOUT }) + .invoke("text") + .then((text) => parseInt(text, 10) || 0); + } + + /** + * Get attachments count + */ + getAttachmentsCount(): Cypress.Chainable { + return cy + .get(this.countBadges.attachments, { timeout: this.STANDARD_TIMEOUT }) + .invoke("text") + .then((text) => parseInt(text, 10) || 0); + } + + /** + * Get links count + */ + getLinksCount(): Cypress.Chainable { + return cy + .get(this.countBadges.links, { timeout: this.STANDARD_TIMEOUT }) + .invoke("text") + .then((text) => parseInt(text, 10) || 0); + } + + // ============ Details Tab Methods ============ + + /** + * Verify application status + */ + verifyApplicationStatus(expectedStatus: string): this { + cy.get(this.detailsSection.applicationStatusWidget, { timeout: this.STANDARD_TIMEOUT }) + .should("contain.text", expectedStatus); + return this; + } + + /** + * Get summary field value by label + */ + getSummaryFieldValue(label: string): Cypress.Chainable { + return cy + .get(this.detailsSection.summaryTable, { timeout: this.STANDARD_TIMEOUT }) + .contains(".display-input-label", label) + .siblings(".display-input") + .invoke("text") + .then((text) => text.trim()); + } + + /** + * Verify summary field value + */ + verifySummaryFieldValue(label: string, expectedValue: string): this { + cy.get(this.detailsSection.summaryTable, { timeout: this.STANDARD_TIMEOUT }) + .contains(".display-input-label", label) + .siblings(".display-input") + .should("contain.text", expectedValue); + return this; + } + + // ============ Assessment Scores Methods ============ + + /** + * Enter financial analysis score + */ + enterFinancialAnalysis(score: string): this { + cy.get(this.assessmentSection.financialAnalysis, { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(score); + return this; + } + + /** + * Enter economic impact score + */ + enterEconomicImpact(score: string): this { + cy.get(this.assessmentSection.economicImpact, { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(score); + return this; + } + + /** + * Enter inclusive growth score + */ + enterInclusiveGrowth(score: string): this { + cy.get(this.assessmentSection.inclusiveGrowth, { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(score); + return this; + } + + /** + * Enter clean growth score + */ + enterCleanGrowth(score: string): this { + cy.get(this.assessmentSection.cleanGrowth, { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(score); + return this; + } + + /** + * Click save assessment scores button + */ + clickSaveAssessmentScores(): this { + cy.get(this.assessmentSection.saveAssessmentScoresBtn, { timeout: this.STANDARD_TIMEOUT }) + .should("not.be.disabled") + .click({ force: true }); + return this; + } + + /** + * Select recommendation + */ + selectRecommendation(recommendation: "true" | "false"): this { + cy.get(this.assessmentSection.recommendationSelect, { timeout: this.STANDARD_TIMEOUT }) + .select(recommendation); + return this; + } + + /** + * Click reset recommendation button + */ + clickResetRecommendation(): this { + cy.get(this.assessmentSection.recommendationResetBtn, { timeout: this.STANDARD_TIMEOUT }) + .click({ force: true }); + return this; + } + + // ============ Email Methods ============ + + /** + * Click New Email button + */ + clickNewEmail(): this { + cy.get(this.emailSection.newEmailBtn, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Select email template + */ + selectEmailTemplate(templateName: string): this { + cy.get(this.emailSection.templateSelect, { timeout: this.STANDARD_TIMEOUT }) + .select(templateName); + return this; + } + + /** + * Enter email To address + */ + enterEmailTo(email: string): this { + cy.get(this.emailSection.emailTo, { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(email); + return this; + } + + /** + * Enter email CC address + */ + enterEmailCC(email: string): this { + cy.get(this.emailSection.emailCC, { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(email); + return this; + } + + /** + * Enter email BCC address + */ + enterEmailBCC(email: string): this { + cy.get(this.emailSection.emailBCC, { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(email); + return this; + } + + /** + * Enter email subject + */ + enterEmailSubject(subject: string): this { + cy.get(this.emailSection.emailSubject, { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(subject); + return this; + } + + /** + * Enter email body + */ + enterEmailBody(body: string): this { + cy.get(this.emailSection.emailBody, { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(body); + return this; + } + + /** + * Click Save email button + */ + clickSaveEmail(): this { + cy.get(this.emailSection.saveBtn, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Click Send email button + */ + clickSendEmail(): this { + cy.get(this.emailSection.sendBtn, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Click Confirm Send email button + */ + clickConfirmSendEmail(): this { + cy.get(this.emailSection.confirmSendBtn, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Click Cancel email button + */ + clickCancelEmail(): this { + cy.get(this.emailSection.cancelBtn, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + // ============ Comments Methods ============ + + /** + * Add a comment + */ + addComment(comment: string): this { + cy.get(this.commentsSection.commentTextArea, { timeout: this.STANDARD_TIMEOUT }) + .first() + .clear() + .type(comment); + return this; + } + + /** + * Click save comment button + */ + clickSaveComment(): this { + cy.get(this.commentsSection.addCommentSaveBtn, { timeout: this.STANDARD_TIMEOUT }) + .first() + .click({ force: true }); + return this; + } + + /** + * Click cancel comment button + */ + clickCancelComment(): this { + cy.get(this.commentsSection.addCommentCancelBtn, { timeout: this.STANDARD_TIMEOUT }) + .first() + .click({ force: true }); + return this; + } + + /** + * Verify comment exists + */ + verifyCommentExists(commentText: string): this { + cy.get(this.commentsSection.commentsContainer, { timeout: this.STANDARD_TIMEOUT }) + .should("contain.text", commentText); + return this; + } + + // ============ Attachments Methods ============ + + /** + * Click upload attachment button + */ + clickUploadAttachment(): this { + cy.get(this.attachmentsSection.uploadBtn, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Upload file attachment + * Uses the Submission Attachments file input + */ + uploadAttachment(filePath: string): this { + // Try the submission attachments file input first + const selectors = [ + "#addSubmissionAttachmentsFile", + "#application_upload", + "#attachments input[type='file']", + "input[type='file'][id*='attachment']", + "input[type='file'][id*='Attachment']", + "input[type='file']", + ]; + + cy.get("body").then(($body) => { + let fileInput = null; + + for (const selector of selectors) { + const $el = $body.find(selector); + if ($el.length > 0) { + fileInput = selector; + break; + } + } + + if (fileInput) { + cy.get(fileInput).first().selectFile(filePath, { force: true }); + } else { + // Click Add Attachments button to trigger file input + cy.contains("Add Attachments", { timeout: this.STANDARD_TIMEOUT }) + .click({ force: true }); + cy.wait(500); + cy.get("input[type='file']").first().selectFile(filePath, { force: true }); + } + }); + + // Wait for upload to complete + cy.wait(3000); + return this; + } + + /** + * Upload a unique attachment with generated content + * @param fileName - The filename to use + * @param timestamp - Timestamp for unique content + */ + uploadUniqueAttachment(fileName: string, timestamp: number): this { + cy.get("#attachments input[type='file']", { timeout: this.STANDARD_TIMEOUT }) + .first() + .selectFile( + { + contents: Cypress.Buffer.from(`Test attachment content - ${timestamp}`), + fileName: fileName, + mimeType: "text/plain", + }, + { force: true } + ); + return this; + } + + /** + * Verify attachments table has rows + */ + verifyAttachmentsTableHasRows(): this { + cy.get(this.attachmentsSection.attachmentsTable, { timeout: this.STANDARD_TIMEOUT }) + .find("tbody tr") + .should("have.length.at.least", 1); + return this; + } + + /** + * Verify attachment exists by name + */ + verifyAttachmentExists(fileName: string): this { + cy.get("#attachments", { timeout: this.STANDARD_TIMEOUT }) + .should("contain.text", fileName); + return this; + } + + // ============ Links Methods ============ + + /** + * Click add link button + */ + clickAddLink(): this { + cy.get(this.linksSection.addLinkBtn, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Verify links table has rows + */ + verifyLinksTableHasRows(): this { + cy.get(this.linksSection.linksTable, { timeout: this.STANDARD_TIMEOUT }) + .find("tbody tr") + .should("have.length.at.least", 1); + return this; + } + + // ============ History Methods ============ + + /** + * Verify history table has rows + */ + verifyHistoryTableHasRows(): this { + cy.get(this.historySection.historyTable, { timeout: this.STANDARD_TIMEOUT }) + .find("tbody tr") + .should("have.length.at.least", 1); + return this; + } + + /** + * Verify history contains action + */ + verifyHistoryContainsAction(action: string): this { + cy.get(this.historySection.historyTable, { timeout: this.STANDARD_TIMEOUT }) + .should("contain.text", action); + return this; + } + + // ============ Verification Methods ============ + + /** + * Verify right panel is visible + */ + verifyRightPanelVisible(): this { + cy.get(this.container, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible"); + return this; + } + + /** + * Verify all tabs are visible + */ + verifyAllTabsVisible(): this { + cy.get(this.tabs.details, { timeout: this.STANDARD_TIMEOUT }).should("be.visible"); + cy.get(this.tabs.emails, { timeout: this.STANDARD_TIMEOUT }).should("be.visible"); + cy.get(this.tabs.comments, { timeout: this.STANDARD_TIMEOUT }).should("be.visible"); + cy.get(this.tabs.attachments, { timeout: this.STANDARD_TIMEOUT }).should("be.visible"); + cy.get(this.tabs.links, { timeout: this.STANDARD_TIMEOUT }).should("be.visible"); + cy.get(this.tabs.history, { timeout: this.STANDARD_TIMEOUT }).should("be.visible"); + return this; + } +} diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts index aae8eb72bb..108d6b0cfe 100644 --- a/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts @@ -186,6 +186,39 @@ export class ApplicationsListPage extends ApplicationsPage { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; } + // ============ Search Methods ============ + + /** + * Search for a submission by ID + */ + searchForSubmission(submissionId: string): this { + cy.get("#search", { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(submissionId); + this.waitForTableRefresh(); + return this; + } + + /** + * Select a row by matching text content + */ + selectRowByText(text: string): this { + cy.contains("tr", text, { timeout: this.STANDARD_TIMEOUT }) + .find(".checkbox-select") + .click({ force: true }); + return this; + } + + /** + * Click the OPEN button (external link) + */ + clickOpenButton(): this { + cy.get("#externalLink", { timeout: this.STANDARD_TIMEOUT }) + .should("exist") + .click({ force: true }); + return this; + } + // ============ Extended Table Methods ============ /** diff --git a/applications/Unity.AutoUI/cypress/pages/ReviewAssessmentPage.ts b/applications/Unity.AutoUI/cypress/pages/ReviewAssessmentPage.ts new file mode 100644 index 0000000000..4d27a480c0 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/pages/ReviewAssessmentPage.ts @@ -0,0 +1,445 @@ +/// + +import { BasePage } from "./BasePage"; + +/** + * ReviewAssessmentPage - Page Object for the Review & Assessment tab + * Handles form fields inside shadow DOM (Form.io) + */ +export class ReviewAssessmentPage extends BasePage { + private readonly STANDARD_TIMEOUT = 20000; + + // Tab container selectors + private readonly containers = { + detailsTabContent: "#detailsTabContent", + submissionTab: "#nav-summery", + reviewAssessmentTab: "#nav-review-and-assessment", + formioContainer: "#formio", + }; + + // Section header selectors (card headers) + private readonly sections = { + introduction: 'h4.card-title:contains("1. INTRODUCTION")', + eligibility: 'h4.card-title:contains("2. ELIGIBILITY")', + applicantInfo: 'h4.card-title:contains("3. APPLICANT INFORMATION")', + projectInfo: 'h4.card-title:contains("4. PROJECT INFORMATION")', + projectTimelines: 'h4.card-title:contains("5. PROJECT TIMELINES")', + projectBudget: 'h4.card-title:contains("6. PROJECT BUDGET")', + attestation: 'h4.card-title:contains("7. ATTESTATION")', + }; + + // Organization Info panel selectors (using name attribute) + private readonly organizationInfo = { + applicantName: 'input[name="data[_ApplicantName]"]', + registeredBusinessName: 'input[name="data[_dateExtractBusinessName]"]', + registeredBusinessNumber: 'input[name="data[_registeredBusinessNumber]"]', + businessName: 'input[name="data[_OrganizationName]"]', + organizationType: 'select[name="data[_OrganizationType]"]', + orgBookStatus: 'select[name="data[_OrgBookStatus]"]', + riskRanking: 'select[name="data[_riskRanking]"]', + sector: 'select[name="data[sector]"]', + subsector: 'select[name="data[subsector]"]', + otherSubsector: 'textarea[name="data[_OtherSubSector]"]', + }; + + // Contact Info panel selectors + private readonly contactInfo = { + contactName: 'input[name="data[_ContactName]"]', + contactTitle: 'input[name="data[_ContactTitle]"]', + contactEmail: 'input[name="data[_ContactEmail]"]', + contactPhonePrimary: 'input[name="data[_ContactPhoneNumberPrimary]"]', + contactPhoneSecondary: 'input[name="data[_ContactPhoneNumberSecondary]"]', + }; + + // Mailing Address panel selectors + private readonly mailingAddress = { + unit: 'input[name="data[_MailingAddressUnit]"]', + street1: 'input[name="data[_MailingAddressStreet1]"]', + street2: 'input[name="data[_MailingAddressStreet2]"]', + city: 'input[name="data[_MailingAddressCity]"]', + province: 'select[name="data[_MailingAddressProvince]"]', + postalCode: 'input[name="data[_MailingAddressPostalCode]"]', + }; + + // Project Info selectors + private readonly projectInfo = { + projectName: 'input[name="data[_ProjectName]"]', + projectDescription: 'textarea[name="data[_ProjectDescription]"]', + economicRegion: 'select[name="data[_EconomicRegion]"]', + regionalDistrict: 'select[name="data[_RegionalDistrict]"]', + community: 'select[name="data[_Community]"]', + }; + + // Project Budget selectors + private readonly projectBudget = { + requestedAmount: 'input[name="data[_RequestedAmount]"]', + totalProjectBudget: 'input[name="data[_TotalProjectBudget]"]', + }; + + // Assessment selectors + private readonly assessment = { + approvedAmount: '#ApprovalView_ApprovedAmount', + decisionDate: '#ApprovalView_FinalDecisionDate', + saveButton: 'button:contains("Save")', + // Assessment List view buttons + createAssessmentButton: '#CreateButton', + completeAssessmentButton: '#CompleteButton', + assessmentMainView: '#assessmentMainView', + }; + + constructor() { + super(); + } + + // ============ Section Methods ============ + + /** + * Expand a section by clicking its header + */ + expandSection( + sectionName: + | "introduction" + | "eligibility" + | "applicantInfo" + | "projectInfo" + | "projectTimelines" + | "projectBudget" + | "attestation" + ): this { + cy.contains("h4.card-title", this.getSectionTitle(sectionName), { + timeout: this.STANDARD_TIMEOUT, + }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Get the full section title text + */ + private getSectionTitle(sectionName: string): string { + const titles: Record = { + introduction: "1. INTRODUCTION", + eligibility: "2. ELIGIBILITY", + applicantInfo: "3. APPLICANT INFORMATION", + projectInfo: "4. PROJECT INFORMATION", + projectTimelines: "5. PROJECT TIMELINES", + projectBudget: "6. PROJECT BUDGET", + attestation: "7. ATTESTATION", + }; + return titles[sectionName] || sectionName; + } + + /** + * Verify a section exists + */ + verifySectionExists( + sectionName: + | "introduction" + | "eligibility" + | "applicantInfo" + | "projectInfo" + | "projectTimelines" + | "projectBudget" + | "attestation" + ): this { + cy.contains("h4.card-title", this.getSectionTitle(sectionName), { + timeout: this.STANDARD_TIMEOUT, + }).should("exist"); + return this; + } + + // ============ Organization Info Methods ============ + + /** + * Get applicant name value + */ + getApplicantName(): Cypress.Chainable { + return cy + .get(this.organizationInfo.applicantName, { timeout: this.STANDARD_TIMEOUT }) + .invoke("val") + .then((val) => String(val)); + } + + /** + * Verify applicant name + */ + verifyApplicantName(expectedValue: string): this { + cy.get(this.organizationInfo.applicantName, { timeout: this.STANDARD_TIMEOUT }) + .should("have.value", expectedValue); + return this; + } + + /** + * Get registered business name + */ + getRegisteredBusinessName(): Cypress.Chainable { + return cy + .get(this.organizationInfo.registeredBusinessName, { timeout: this.STANDARD_TIMEOUT }) + .invoke("val") + .then((val) => String(val)); + } + + /** + * Verify registered business name + */ + verifyRegisteredBusinessName(expectedValue: string): this { + cy.get(this.organizationInfo.registeredBusinessName, { timeout: this.STANDARD_TIMEOUT }) + .should("have.value", expectedValue); + return this; + } + + /** + * Get registered business number + */ + getRegisteredBusinessNumber(): Cypress.Chainable { + return cy + .get(this.organizationInfo.registeredBusinessNumber, { timeout: this.STANDARD_TIMEOUT }) + .invoke("val") + .then((val) => String(val)); + } + + /** + * Verify registered business number + */ + verifyRegisteredBusinessNumber(expectedValue: string): this { + cy.get(this.organizationInfo.registeredBusinessNumber, { timeout: this.STANDARD_TIMEOUT }) + .should("have.value", expectedValue); + return this; + } + + // ============ Contact Info Methods ============ + + /** + * Verify contact name + */ + verifyContactName(expectedValue: string): this { + cy.get(this.contactInfo.contactName, { timeout: this.STANDARD_TIMEOUT }) + .should("have.value", expectedValue); + return this; + } + + /** + * Verify contact title + */ + verifyContactTitle(expectedValue: string): this { + cy.get(this.contactInfo.contactTitle, { timeout: this.STANDARD_TIMEOUT }) + .should("have.value", expectedValue); + return this; + } + + /** + * Verify contact email + */ + verifyContactEmail(expectedValue: string): this { + cy.get(this.contactInfo.contactEmail, { timeout: this.STANDARD_TIMEOUT }) + .should("have.value", expectedValue); + return this; + } + + /** + * Verify contact phone primary + */ + verifyContactPhonePrimary(expectedValue: string): this { + cy.get(this.contactInfo.contactPhonePrimary, { timeout: this.STANDARD_TIMEOUT }) + .should("have.value", expectedValue); + return this; + } + + /** + * Verify contact phone secondary + */ + verifyContactPhoneSecondary(expectedValue: string): this { + cy.get(this.contactInfo.contactPhoneSecondary, { timeout: this.STANDARD_TIMEOUT }) + .should("have.value", expectedValue); + return this; + } + + // ============ Mailing Address Methods ============ + + /** + * Verify mailing address city + */ + verifyMailingCity(expectedValue: string): this { + cy.get(this.mailingAddress.city, { timeout: this.STANDARD_TIMEOUT }) + .should("have.value", expectedValue); + return this; + } + + /** + * Verify mailing address street 1 + */ + verifyMailingStreet1(expectedValue: string): this { + cy.get(this.mailingAddress.street1, { timeout: this.STANDARD_TIMEOUT }) + .should("have.value", expectedValue); + return this; + } + + /** + * Verify mailing address postal code + */ + verifyMailingPostalCode(expectedValue: string): this { + cy.get(this.mailingAddress.postalCode, { timeout: this.STANDARD_TIMEOUT }) + .should("have.value", expectedValue); + return this; + } + + // ============ Panel Methods ============ + + /** + * Expand Organization Info panel + */ + expandOrganizationInfoPanel(): this { + cy.contains(".card-header", "Organization Info", { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Expand Contact Info panel + */ + expandContactInfoPanel(): this { + cy.contains(".card-header", "Contact Info", { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Expand Mailing Address panel + */ + expandMailingAddressPanel(): this { + cy.contains(".card-header", "Mailing Address", { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + // ============ Verification Methods ============ + + /** + * Verify all main sections are present + */ + verifyAllSectionsPresent(): this { + this.verifySectionExists("introduction"); + this.verifySectionExists("eligibility"); + this.verifySectionExists("applicantInfo"); + this.verifySectionExists("projectInfo"); + this.verifySectionExists("projectTimelines"); + this.verifySectionExists("projectBudget"); + this.verifySectionExists("attestation"); + return this; + } + + /** + * Verify formio container is loaded + */ + verifyFormioLoaded(): this { + cy.get(this.containers.formioContainer, { timeout: this.STANDARD_TIMEOUT }) + .should("exist"); + return this; + } + + /** + * Get field value by name attribute + */ + getFieldValue(fieldName: string): Cypress.Chainable { + return cy + .get(`input[name="data[${fieldName}]"]`, { timeout: this.STANDARD_TIMEOUT }) + .invoke("val") + .then((val) => String(val)); + } + + /** + * Verify field value by name attribute + */ + verifyFieldValue(fieldName: string, expectedValue: string): this { + cy.get(`input[name="data[${fieldName}]"]`, { timeout: this.STANDARD_TIMEOUT }) + .should("have.value", expectedValue); + return this; + } + + /** + * Verify select field has expected text (for Choices.js dropdowns) + */ + verifySelectFieldText(fieldName: string, expectedText: string): this { + cy.get(`select[name="data[${fieldName}]"]`, { timeout: this.STANDARD_TIMEOUT }) + .parent() + .find(".choices__item--selectable") + .should("contain.text", expectedText); + return this; + } + + // ============ Assessment Methods ============ + + /** + * Enter approved amount + */ + enterApprovedAmount(amount: string): this { + cy.get(this.assessment.approvedAmount, { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(amount); + return this; + } + + /** + * Set decision date to today (format: YYYY-MM-DD) + */ + setDecisionDateToToday(): this { + const today = new Date().toISOString().split("T")[0]; + cy.get(this.assessment.decisionDate, { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(today); + return this; + } + + /** + * Set decision date to a specific date + */ + setDecisionDate(date: string): this { + cy.get(this.assessment.decisionDate, { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(date); + return this; + } + + /** + * Click Save button + */ + clickSave(): this { + cy.contains("button", "Save", { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Scroll to Assessment List section + */ + scrollToAssessmentList(): this { + cy.get(this.assessment.assessmentMainView, { timeout: this.STANDARD_TIMEOUT }) + .scrollIntoView(); + return this; + } + + /** + * Click Create Assessment button in Assessment List view + */ + clickCreateAssessment(): this { + cy.get(this.assessment.createAssessmentButton, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Click Complete Assessment button in Assessment List view + */ + clickCompleteAssessment(): this { + cy.get(this.assessment.completeAssessmentButton, { timeout: this.STANDARD_TIMEOUT }) + .should("not.be.disabled") + .click({ force: true }); + return this; + } +} diff --git a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts new file mode 100644 index 0000000000..d34c424860 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts @@ -0,0 +1,225 @@ +/// + +/** + * Approval Flow Regression Test - Full Approval Workflow + * + * This test validates the complete application approval workflow including: + * - Dynamic submission ID fetching from API + * - Searching and opening a submission + * - Review and assessment process + * - Payment info configuration + * - Adding comments and attachments + * - Approval action (confirmed via dialog) + * + * The submission ID is fetched dynamically from the API after login, + * ensuring tests always run against valid, available data. + */ + +import { ApplicationsListPage } from "../pages/ApplicationsListPage"; +import { ApplicationDetailsPage } from "../pages/ApplicationDetailsPage"; +import { ReviewAssessmentPage } from "../pages/ReviewAssessmentPage"; +import { ApplicationDetailsRightTabPage } from "../pages/ApplicationDetailsRightTabPage"; +import { loginIfNeeded } from "../support/auth"; + +const isProd = (Cypress.env("CHEFS_ENV") || Cypress.env("environment") || "").toLowerCase() === "prod"; + +// ============ Test Configuration ============ +// Set submissionId to null for dynamic fetch, or provide a value to override +const TEST_CONFIG = { + // Dynamic submission: set to null to fetch from API, or provide ID to use static value + submissionId: null as string | null, + grantProgram: "Default Grants Program", + approvedAmount: "5000", + supplierNumber: Cypress.env("environment") === "TEST" ? "2002712" : "2009366", + paymentGroup: "Cheque" as const, + testComment: "Test comment from automated regression test", + // Options for dynamic submission fetching (only used when submissionId is null) + // Results are sorted by submissionDate descending (latest first) by default + fetchOptions: { + // Filter by category (required for this test) + categoryFilter: "Data Seeder", + // Filter by status (uncomment to enable): + // Available: 'Submitted', 'Under Assessment', 'Approved', 'Closed', 'Deferred' + statusFilter: ["Submitted"], + // Limit to submissions within N days (uncomment to enable): + maxAge: 30, + // Which submission to use after sorting (0 = latest, 1 = second-latest, etc.) + // Use index > 0 to avoid picking the same submission as other concurrent tests + index: 0, + }, +}; + +(isProd ? describe.skip : describe)("Approval Flow Regression Test", () => { + // Page object instances (reused across all tests) + const listPage = new ApplicationsListPage(); + const detailsPage = new ApplicationDetailsPage(); + const reviewPage = new ReviewAssessmentPage(); + const rightTabPage = new ApplicationDetailsRightTabPage(); + + // Dynamic submission ID - populated after login + let submissionId: string; + + before(() => { + Cypress.config("includeShadowDom", true); + loginIfNeeded(); + }); + + // ============ Dynamic Submission Fetch ============ + + it("Fetch submission ID from API", () => { + // Use static ID if provided, otherwise fetch dynamically + if (TEST_CONFIG.submissionId) { + submissionId = TEST_CONFIG.submissionId; + cy.log(`📌 Using static submission ID: ${submissionId}`); + return; + } + + // Fetch submission ID dynamically from API using session cookies + cy.fetchDynamicSubmission(TEST_CONFIG.fetchOptions).then((id) => { + submissionId = id; + cy.log(`✅ Fetched dynamic submission ID: ${submissionId}`); + }); + }); + + // ============ Navigation & Search ============ + + it("Switch to grant program", () => { + listPage.switchToGrantProgram(TEST_CONFIG.grantProgram); + }); + + it("Search for submission", () => { + // Ensure submissionId is available before searching + expect(submissionId, "Submission ID should be set").to.exist; + listPage + .selectQuickDateRange("alltime") + .waitForTableRefresh() + .searchForSubmission(submissionId); + }); + + it("Select submission and open details", () => { + listPage.selectRowByText(submissionId).clickOpenButton(); + }); + + // ============ Review & Assessment ============ + + it("Navigate to Review and Assessment tab", () => { + detailsPage.goToReviewAssessmentTab().verifyActiveTab("reviewAssessment"); + }); + + it("Enter approval details and save", () => { + reviewPage + .verifyFormioLoaded() + .enterApprovedAmount(TEST_CONFIG.approvedAmount) + .setDecisionDateToToday() + .clickSave(); + }); + + it("Create and complete assessment", () => { + // Wait for assessment section to load + cy.wait(2000); + reviewPage.scrollToAssessmentList(); + + // Check if Create button exists and click it + cy.get("body").then(($body) => { + if ($body.find("#CreateButton").length > 0) { + cy.get("#CreateButton").click({ force: true }); + cy.wait(1000); + } else { + cy.log("Create Assessment button not found - may already be created"); + } + }); + + // Check if Complete button exists and click it + cy.get("body").then(($body) => { + if ($body.find("#CompleteButton").length > 0) { + cy.get("#CompleteButton").click({ force: true }); + cy.wait(1000); + } else { + cy.log( + "Complete Assessment button not found - may already be completed", + ); + } + }); + }); + + // ============ Payment Info ============ + + it("Configure payment info", () => { + // Reload page to get fresh data and avoid concurrency issues + cy.reload(); + cy.wait(2000); + detailsPage + .goToPaymentInfoTab() + .enterSupplierNumber(TEST_CONFIG.supplierNumber) + .clickElsewhere() + .clickPaymentInfoSave(); + }); + + it("Validate and edit site info", () => { + detailsPage + .verifySiteInfoTablePopulated() + .verifySiteInfoTableHasData() + .clickSiteInfoEdit() + .waitForEditSiteModal() + .selectPaymentGroup(TEST_CONFIG.paymentGroup) + .clickSaveChanges(); + }); + + // ============ Comments & Attachments ============ + + it("Add a comment", () => { + // Dismiss any error modals from previous steps + detailsPage.dismissErrorModalIfPresent(); + rightTabPage + .goToCommentsTab() + .addComment(TEST_CONFIG.testComment) + .clickSaveComment(); + }); + + it("Add an attachment", () => { + // Dismiss any error modals from previous steps + detailsPage.dismissErrorModalIfPresent(); + rightTabPage.goToAttachmentsTab(); + cy.wait(1000); // Allow tab content to load + + // Store initial count to verify upload + rightTabPage.getAttachmentsCount().then((initialCount) => { + cy.log(`Initial attachment count: ${initialCount}`); + + // Generate unique filename to ensure new file is added + const timestamp = Date.now(); + const uniqueFileName = `test-attachment-${timestamp}.txt`; + + // Upload file with unique content + rightTabPage.uploadUniqueAttachment(uniqueFileName, timestamp); + + // Verify upload success + cy.contains("Successful").should("be.visible"); + cy.wait(2000); // Allow UI to update + + // Verify count increased + rightTabPage.getAttachmentsCount().then((newCount) => { + cy.log(`New attachment count: ${newCount}`); + expect(newCount).to.be.greaterThan(initialCount); + }); + + // Verify file appears in list + rightTabPage.verifyAttachmentExists(uniqueFileName); + cy.screenshot("attachment-upload-complete"); + }); + }); + + // ============ Approval Action ============ + + it("Test approval workflow (confirm)", () => { + // Dismiss any error modals from previous steps + detailsPage.dismissErrorModalIfPresent(); + detailsPage.clickApprove().waitForConfirmModal().clickConfirm(); + }); + + // ============ Cleanup ============ + + it("Logout", () => { + cy.logout(); + }); +}); diff --git a/applications/Unity.AutoUI/cypress/scripts/README.md b/applications/Unity.AutoUI/cypress/scripts/README.md new file mode 100644 index 0000000000..528f451a64 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/scripts/README.md @@ -0,0 +1,164 @@ +# CHEFS API Testing + +This directory contains Cypress tests for the CHEFS (Common Hosted Form Service) API. + +## Files + +- **chefs-api-submission.cy.ts**: Cypress test spec for CHEFS form submissions +- **chefs-api-config.json**: Environment configuration (baseURL, formId, versionId, headers) +- **chefs-submission-payload.json**: Form submission payload template + +## Setup + +### 1. Get a Valid CHEFS Authentication Token + +JWT tokens expire regularly (typically within hours/days). To get a fresh token: + +#### Option A: From Browser DevTools + +1. Navigate to CHEFS test environment: https://chefs-test.apps.silver.devops.gov.bc.ca +2. Login with your IDIR credentials +3. Open browser DevTools (F12) +4. Go to the **Network** tab +5. Submit a form or perform any API action +6. Find an API request to `/app/api/v1/forms/` +7. Click on the request and go to **Headers** +8. Copy the `Authorization` header value (starts with `Bearer eyJ...`) + +#### Option B: From Curl Command + +If you have a working curl command: + +```bash +curl 'https://chefs-test.apps.silver.devops.gov.bc.ca/...' \ + -H 'authorization: Bearer eyJhbGc...' +``` + +Copy the token from the authorization header. + +### 2. Update cypress.env.json + +Add or update the token in `cypress.env.json`: + +```json +{ + "CHEFS_AUTH_TOKEN": "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +**Note**: The `Bearer` prefix is optional - the test will handle both formats. + +### 3. Update Configuration (if needed) + +Edit `chefs-api-config.json` to match your environment: + +```json +{ + "environments": { + "test": { + "baseURL": "https://chefs-test.apps.silver.devops.gov.bc.ca", + "formId": "your-form-id-here", + "versionId": "your-version-id-here" + } + } +} +``` + +## Running the Tests + +```bash +# Run all CHEFS API tests +npx cypress run --spec "cypress/scripts/chefs-api-submission.cy.ts" + +# Run in headed mode (see browser) +npx cypress open --spec "cypress/scripts/chefs-api-submission.cy.ts" +``` + +## Test Cases + +1. **Submit form via CHEFS API**: Submit a complete form with all fields +2. **Submit with custom data**: Override specific fields (applicant name, project title, etc.) +3. **Draft submission**: Submit as draft (not final) +4. **Retrieve submission**: Get submission details by ID +5. **Update submission files**: Add/update file attachments + +## Customizing the Payload + +Edit `chefs-submission-payload.json` to customize the form data: + +```json +{ + "submission": { + "data": { + "_ApplicantName": "Your Custom Name", + "_organizationName": "Your Organization", + "_projectTitle": "Your Project", + "_fundingRequest": 50000 + // ... other fields + } + } +} +``` + +## Troubleshooting + +### 401 Unauthorized Error + +**Cause**: Token is expired or invalid + +**Solution**: + +1. Get a fresh token (see Setup step 1) +2. Update `cypress.env.json` with the new token +3. Re-run the tests + +### 400 Bad Request Error + +**Cause**: Payload data doesn't match form schema + +**Solution**: + +1. Check form version ID in `chefs-api-config.json` +2. Verify payload structure matches the CHEFS form schema +3. Use browser DevTools to capture a valid submission payload + +### Test Skipped (No Token) + +**Cause**: `CHEFS_AUTH_TOKEN` not set in `cypress.env.json` + +**Solution**: Add token to `cypress.env.json` (see Setup step 2) + +## Security Note + +⚠️ **Never commit tokens to version control** + +- Add `cypress.env.json` to `.gitignore` +- Use environment variables in CI/CD pipelines +- Rotate tokens regularly +- Store tokens securely (e.g., password manager, CI secrets) + +## CI/CD Integration + +For automated testing in pipelines: + +```bash +# Set token as environment variable +export CYPRESS_CHEFS_AUTH_TOKEN="Bearer eyJhbGc..." + +# Run tests +npx cypress run --spec "cypress/scripts/chefs-api-submission.cy.ts" +``` + +Or use Cypress environment variable syntax in your CI config: + +```yaml +# Example GitHub Actions +env: + CYPRESS_CHEFS_AUTH_TOKEN: ${{ secrets.CHEFS_AUTH_TOKEN }} +``` + +```yaml +# Example GitLab CI +variables: + CYPRESS_CHEFS_AUTH_TOKEN: $CHEFS_AUTH_TOKEN # From CI/CD variables +``` diff --git a/applications/Unity.AutoUI/cypress/scripts/chefs-api-config.json b/applications/Unity.AutoUI/cypress/scripts/chefs-api-config.json new file mode 100644 index 0000000000..0bf9000101 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/scripts/chefs-api-config.json @@ -0,0 +1,23 @@ +{ + "environments": { + "test": { + "baseURL": "https://chefs-test.apps.silver.devops.gov.bc.ca", + "formId": "46e25863-0ead-4aa8-897f-51e45f79e137", + "versionId": "4ef52ead-2cc3-4bdb-a7b7-73be983a7838" + }, + "dev": { + "baseURL": "https://chefs-dev.apps.silver.devops.gov.bc.ca", + "formId": "233f47f9-b566-46c3-926a-73d565bf710f", + "versionId": "1e209d6b-46f5-4ddb-bc79-6e04033231cb" + }, + "uat": { + "baseURL": "https://chefs-test.apps.silver.devops.gov.bc.ca", + "formId": "f2f45aa7-62c5-49ca-8846-b214e02adb46", + "versionId": "1d4d73ec-00e7-4b57-98c9-49d1e0c7d15b" + } + }, + "headers": { + "Accept": "application/json", + "Content-Type": "application/json" + } +} \ No newline at end of file diff --git a/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts b/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts new file mode 100644 index 0000000000..ff81ec0ffd --- /dev/null +++ b/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts @@ -0,0 +1,514 @@ +/// + +export {}; + +/** + * CHEFS Form Submission API Test + * + * This test submits a form to CHEFS (Common Hosted Form Service) via API + * All payloads and configuration are customizable via JSON files: + * - cypress/scripts/chefs-submission-payload.json - Form submission data + * - cypress/scripts/chefs-api-config.json - API configuration and headers + */ + +interface ChefsEnvironment { + baseURL: string; + formId: string; + versionId: string; +} + +interface ChefsApiConfig { + environments: Record; + headers: Record; +} + +interface ChefsSubmissionPayload { + draft?: boolean; + submission: { + state: string; + metadata: { + origin: string; + referrer: string; + }; + data: Record; + }; +} + +const TOKEN_PROPERTY_KEYS = [ + "access_token", + "accessToken", + "token", + "id_token", + "idToken", +]; + +function isJwtLike(value: string): boolean { + return /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/.test(value); +} + +function extractTokenFromString(value: string): string { + const trimmed = value.trim(); + + if (trimmed.toLowerCase().startsWith("bearer ")) { + const bearerToken = trimmed.replace(/^Bearer\s+/i, "").trim(); + if (isJwtLike(bearerToken)) { + return bearerToken; + } + } + + if (isJwtLike(trimmed)) { + return trimmed; + } + + try { + return extractTokenFromValue(JSON.parse(trimmed)); + } catch { + return ""; + } +} + +function extractTokenFromArray(values: unknown[]): string { + for (const value of values) { + const token = extractTokenFromValue(value); + if (token) { + return token; + } + } + + return ""; +} + +function extractTokenFromObject(value: Record): string { + for (const key of TOKEN_PROPERTY_KEYS) { + const token = extractTokenFromValue(value[key]); + if (token) { + return token; + } + } + + return extractTokenFromArray(Object.values(value)); +} + +function extractTokenFromValue(value: unknown): string { + if (typeof value === "string") { + return extractTokenFromString(value); + } + + if (Array.isArray(value)) { + return extractTokenFromArray(value); + } + + if (value && typeof value === "object") { + return extractTokenFromObject(value as Record); + } + + return ""; +} + +function extractTokenFromStorage(win: Window): string { + const storages = [win.localStorage, win.sessionStorage]; + + for (const storage of storages) { + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (!key) { + continue; + } + + const value = storage.getItem(key); + if (!value) { + continue; + } + + const token = extractTokenFromValue(value); + if (token) { + return token; + } + } + } + + return ""; +} + +function getChefsHostname(baseURL: string): string { + return new URL(baseURL).hostname; +} + +function waitForIdentityRedirectOrAuthenticatedChefsPage( + baseURL: string, + timeout: number, +): void { + const chefsHostname = getChefsHostname(baseURL); + + cy.location("hostname", { timeout }).should((hostname) => { + const onChefs = hostname === chefsHostname; + const onBcGovIdentity = hostname.endsWith("gov.bc.ca"); + + expect( + onChefs || onBcGovIdentity, + `Expected CHEFS or BC Gov identity host, got '${hostname}'`, + ).to.eq(true); + }); +} + +function completeChefsLogin(environment: ChefsEnvironment, timeout: number): void { + const chefsHostname = getChefsHostname(environment.baseURL); + + cy.visit(`${environment.baseURL}/app`); + + cy.get("#app > div > main > header > header > div > div.d-print-none", { + timeout, + }) + .should("exist") + .click(); + + cy.get( + "#app > div > main > div.v-container.v-locale--is-ltr.text-center.main > div > div:nth-child(2) > div > button", + { timeout }, + ) + .should("exist") + .click(); + + waitForIdentityRedirectOrAuthenticatedChefsPage(environment.baseURL, timeout); + + cy.location("hostname", { timeout }).then((hostname) => { + if (hostname === chefsHostname) { + cy.log("Already logged in to CHEFS"); + return; + } + + cy.get("#user", { timeout }) + .should("be.visible") + .clear() + .type(Cypress.env("test1username"), { log: false }); + + cy.get("#password", { timeout }) + .should("be.visible") + .clear() + .type(Cypress.env("test1password"), { log: false }); + + cy.contains("Continue", { timeout }).should("be.visible").click(); + + cy.location("hostname", { timeout }).should("eq", chefsHostname); + }); +} + +function visitChefsForm(environment: ChefsEnvironment, timeout: number): void { + cy.visit(`${environment.baseURL}/app/form/submit?f=${environment.formId}`); + cy.location("hostname", { timeout }).should( + "eq", + getChefsHostname(environment.baseURL), + ); + cy.location("pathname", { timeout }).should("include", "/app"); +} + +const isProd = + (Cypress.env("CHEFS_ENV") || Cypress.env("environment") || "").toLowerCase() === + "prod"; + +(isProd ? describe.skip : describe)("CHEFS Form Submission API", () => { + let apiConfig: ChefsApiConfig; + let submissionPayload: ChefsSubmissionPayload; + let environment: ChefsEnvironment; + let authToken: string; + let createdSubmissionId: string; + + before(() => { + const authTimeout = 60000; + + cy.readFile("cypress/scripts/chefs-api-config.json").then((config) => { + apiConfig = config; + + const envKey = ( + Cypress.env("CHEFS_ENV") || + Cypress.env("environment") || + "test" + ).toLowerCase(); + + environment = config.environments[envKey]; + + expect( + environment, + `Missing CHEFS environment configuration for '${envKey}'`, + ).to.exist; + + cy.log(`Using environment: ${envKey}`); + cy.log(`Base URL: ${environment.baseURL}`); + cy.log(`Form ID: ${environment.formId}`); + cy.log(`Version ID: ${environment.versionId}`); + + cy.readFile("cypress/scripts/chefs-submission-payload.json").then( + (payload) => { + submissionPayload = payload; + submissionPayload.submission.metadata.origin = environment.baseURL; + submissionPayload.submission.metadata.referrer = `${environment.baseURL}/app/form/submit?f=${environment.formId}`; + + cy.log( + `Payload loaded with ${ + Object.keys(payload.submission.data).length + } data fields`, + ); + cy.log(`Metadata origin set to: ${environment.baseURL}`); + }, + ); + + let capturedToken = ""; + + cy.intercept("**/app/api/v1/**", (req) => { + const authHeader = req.headers["authorization"] as string; + if (authHeader && !capturedToken) { + capturedToken = authHeader.replace(/^Bearer\s+/i, ""); + } + }).as("chefsApiCalls"); + + completeChefsLogin(environment, authTimeout); + visitChefsForm(environment, authTimeout); + + cy.window({ timeout: authTimeout }) + .should((win) => { + const tokenFromStorage = extractTokenFromStorage(win); + const resolvedToken = capturedToken || tokenFromStorage; + + expect( + resolvedToken, + "Waiting for authenticated CHEFS API token from request or browser storage", + ).to.not.equal(""); + + if (!capturedToken && tokenFromStorage) { + capturedToken = tokenFromStorage; + } + }) + .then(() => { + authToken = capturedToken; + cy.log("✅ Auth token captured from CHEFS login"); + }); + }); + }); + + it("should submit form via CHEFS API", () => { + const submissionUrl = `${environment.baseURL}/app/api/v1/forms/${environment.formId}/versions/${environment.versionId}/submissions`; + + cy.log(`Submitting to: ${submissionUrl}`); + + cy.request({ + method: "POST", + url: submissionUrl, + headers: { + ...apiConfig.headers, + Authorization: `Bearer ${authToken}`, + Origin: environment.baseURL, + Referer: `${environment.baseURL}/app/form/submit?f=${environment.formId}`, + }, + body: submissionPayload, + failOnStatusCode: false, + }).then((response) => { + cy.log(`Response Status: ${response.status}`); + cy.log( + `Response Body: ${JSON.stringify(response.body).substring(0, 200)}...`, + ); + + if (response.status === 401) { + cy.log("❌ 401 Unauthorized - Token is expired or invalid"); + cy.log("📖 See cypress/scripts/README.md for token refresh instructions"); + throw new Error( + "Authentication failed (401). Check that test1username/test1password credentials in cypress.env.json are valid and that the CHEFS UI login succeeded during test setup.", + ); + } + + expect(response.status).to.be.oneOf([200, 201]); + expect(response.body).to.have.property("id"); + + if (response.body.id) { + createdSubmissionId = response.body.id; + cy.log(`✅ Submission created with ID: ${response.body.id}`); + } + + expect(response.body).to.have.property("formVersionId", environment.versionId); + + if (response.body.formId) { + expect(response.body.formId).to.eq(environment.formId); + } else { + cy.log("⚠️ Response doesn't include formId (CHEFS version-dependent)"); + } + }); + }); + + it("should submit form with custom data overrides", () => { + const submissionUrl = `${environment.baseURL}/app/api/v1/forms/${environment.formId}/versions/${environment.versionId}/submissions`; + const customPayload = JSON.parse(JSON.stringify(submissionPayload)); + + const timestamp = new Date().toISOString(); + customPayload.submission.data._ApplicantName = `AutoTest_${Date.now()}`; + customPayload.submission.data._projectTitle = `Automated Test Project ${timestamp}`; + customPayload.submission.data._ContactEmail = `autotest_${Date.now()}@example.com`; + customPayload.submission.data._totalProjectCost = 1000000; + customPayload.submission.data._fundingRequest = 750000; + + cy.log("Custom fields set:"); + cy.log(`- Applicant: ${customPayload.submission.data._ApplicantName}`); + cy.log(`- Project: ${customPayload.submission.data._projectTitle}`); + cy.log(`- Email: ${customPayload.submission.data._ContactEmail}`); + + cy.request({ + method: "POST", + url: submissionUrl, + headers: { + ...apiConfig.headers, + Authorization: `Bearer ${authToken}`, + Origin: environment.baseURL, + Referer: `${environment.baseURL}/app/form/submit?f=${environment.formId}`, + }, + body: customPayload, + failOnStatusCode: false, + }).then((response) => { + if (response.status === 401) { + cy.log("❌ 401 Unauthorized - Token is expired or invalid"); + cy.log("📖 See cypress/scripts/README.md for token refresh instructions"); + throw new Error( + "Authentication failed (401). Check that test1username/test1password credentials in cypress.env.json are valid and that the CHEFS UI login succeeded during test setup.", + ); + } + + expect(response.status).to.be.oneOf([200, 201]); + expect(response.body).to.have.property("id"); + + if (response.body.id) { + cy.log(`✅ Custom submission created with ID: ${response.body.id}`); + } + }); + }); + + it("should handle draft submission", () => { + const submissionUrl = `${environment.baseURL}/app/api/v1/forms/${environment.formId}/versions/${environment.versionId}/submissions`; + const draftPayload = JSON.parse(JSON.stringify(submissionPayload)); + + draftPayload.draft = true; + draftPayload.submission.state = "draft"; + draftPayload.submission.data._ApplicantName = `Draft_${Date.now()}`; + + cy.log("Submitting as DRAFT"); + + cy.request({ + method: "POST", + url: submissionUrl, + headers: { + ...apiConfig.headers, + Authorization: `Bearer ${authToken}`, + Origin: environment.baseURL, + Referer: `${environment.baseURL}/app/form/submit?f=${environment.formId}`, + }, + body: draftPayload, + failOnStatusCode: false, + }).then((response) => { + if (response.status === 401) { + cy.log("❌ 401 Unauthorized - Token is expired or invalid"); + cy.log("📖 See cypress/scripts/README.md for token refresh instructions"); + throw new Error( + "Authentication failed (401). Check that test1username/test1password credentials in cypress.env.json are valid and that the CHEFS UI login succeeded during test setup.", + ); + } + + expect(response.status).to.be.oneOf([200, 201]); + expect(response.body).to.have.property("id"); + + if (response.body.draft !== undefined) { + expect(response.body.draft).to.be.true; + cy.log(`✅ Draft submission created with ID: ${response.body.id}`); + } + }); + }); + + it("should retrieve submission by ID", () => { + if (createdSubmissionId) { + const retrieveUrl = `${environment.baseURL}/app/api/v1/submissions/${createdSubmissionId}`; + + cy.request({ + method: "GET", + url: retrieveUrl, + headers: { + ...apiConfig.headers, + Authorization: `Bearer ${authToken}`, + }, + failOnStatusCode: false, + }).then((response) => { + if (response.status === 401) { + cy.log("❌ 401 Unauthorized - CHEFS login credentials may be invalid"); + throw new Error( + "Authentication failed (401). Check that test1username/test1password credentials in cypress.env.json are valid and that the CHEFS UI login succeeded during test setup.", + ); + } + + expect(response.status).to.eq(200); + + if (response.body.submission) { + expect(response.body.submission).to.have.property("id", createdSubmissionId); + cy.log(`✅ Retrieved submission: ${createdSubmissionId}`); + } else if (response.body.id) { + expect(response.body.id).to.eq(createdSubmissionId); + cy.log(`✅ Retrieved submission: ${createdSubmissionId}`); + } else { + cy.log("⚠️ Unexpected response structure - logging for debugging"); + cy.log(JSON.stringify(response.body, null, 2)); + } + }); + } else { + cy.log("⚠️ Skipping - No submission ID available"); + } + }); + + it("should submit form with file attachment", () => { + const filePath = `${Cypress.config("projectRoot")}/cypress/fixtures/test-attachment.txt`; + + cy.task("uploadChefsFile", { + baseURL: environment.baseURL, + authToken: authToken, + filePath: filePath, + }).then((fileRef: any) => { + cy.log(`✅ File uploaded: ${JSON.stringify(fileRef)}`); + + const payloadWithFile = JSON.parse(JSON.stringify(submissionPayload)); + payloadWithFile.submission.data.simplefile = Array.isArray(fileRef) + ? fileRef + : [fileRef]; + + const submissionUrl = `${environment.baseURL}/app/api/v1/forms/${environment.formId}/versions/${environment.versionId}/submissions`; + + cy.request({ + method: "POST", + url: submissionUrl, + headers: { + ...apiConfig.headers, + Authorization: `Bearer ${authToken}`, + Origin: environment.baseURL, + Referer: `${environment.baseURL}/app/form/submit?f=${environment.formId}`, + }, + body: payloadWithFile, + failOnStatusCode: false, + }).then((response) => { + if (response.status === 401) { + throw new Error( + "Authentication failed (401). Check that test1username/test1password credentials in cypress.env.json are valid and that the CHEFS UI login succeeded during test setup.", + ); + } + + expect(response.status).to.be.oneOf([200, 201]); + expect(response.body).to.have.property("id"); + cy.log(`✅ Submission with attachment created: ${response.body.id}`); + }); + }); + }); + + it("should update submission payload data and save back to file", () => { + const updatedPayload = JSON.parse(JSON.stringify(submissionPayload)); + + updatedPayload.submission.data._ApplicantName = "UpdatedApplicant"; + updatedPayload.submission.data._projectTitle = "Updated Project Title"; + + cy.writeFile( + "cypress/scripts/chefs-submission-payload-updated.json", + updatedPayload, + ); + + cy.log("✅ Updated payload saved to chefs-submission-payload-updated.json"); + }); +}); diff --git a/applications/Unity.AutoUI/cypress/scripts/chefs-submission-payload-updated.json b/applications/Unity.AutoUI/cypress/scripts/chefs-submission-payload-updated.json new file mode 100644 index 0000000000..d7f02d0ff3 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/scripts/chefs-submission-payload-updated.json @@ -0,0 +1,193 @@ +{ + "draft": false, + "submission": { + "data": { + "next6": false, + "acceptanceOfEligibilityCriteria": true, + "previous6": false, + "next7": true, + "_ApplicantName": "UpdatedApplicant", + "_organizationName": { + "type": "name", + "sub_type": "entity_name", + "value": "DEC DESIGN", + "topic_source_id": "FM0036035", + "topic_type": "registration.registries.ca", + "credential_type": "registration.registries.ca", + "credential_id": "9ed8e4ee-0209-4548-90f5-7a6846bf3d5a", + "score": 63.596394 + }, + "_dateExtractBusinessName": "DEC DESIGN", + "_hiddenOrganizationName": "DEC DESIGN", + "_registeredBusinessNumber": "FM0036035", + "_hiddenOrganizationNumber": "FM0036035", + "_OrganizationName": "VelangTest", + "_OrganizationType": "CORPORATION", + "_OrgBookStatus": "HISTORICAL", + "_riskRanking": "MEDIUM", + "sector": { + "SectorCode": "21", + "SectorName": "Mining, quarrying, and oil and gas extraction", + "SubSectors": [ + { + "SubSectorCode": "211", + "SubSectorName": "Oil and gas extraction" + }, + { + "SubSectorCode": "212", + "SubSectorName": "Mining and quarrying (except oil and gas)" + }, + { + "SubSectorCode": "213", + "SubSectorName": "Support activities for mining, and oil and gas extraction" + }, + { + "SubSectorCode": "0", + "SubSectorName": "Other" + } + ] + }, + "_hiddenSector": "Mining, quarrying, and oil and gas extraction", + "_hiddenSubsector": "Oil and gas extraction", + "_ContactName": "VelangTest", + "_ContactTitle": "VelangTest", + "_ContactEmail": "VelangTest@VelangTest.cVelangTest", + "_ContactPhoneNumberPrimary": "(987) 654-6545", + "_ContactPhoneNumberSecondary": "(321) 321-3213", + "_MailingAddressUnit": "", + "_MailingAddressStreet1": "11995 Haney Place", + "_MailingAddressStreet2": "", + "_MailingAddressCity": "Maple Ridge", + "_MailingAddressProvince": "British Columbia", + "_MailingAddressCountry": "Canada", + "_MailingAddressPostalCode": "V2X 6G2", + "_PhysicalAddressUnit": "", + "_PhysicalAddressStreet1": "11995 Haney Place", + "_PhysicalAddressStreet2": "", + "_PhysicalAddressCity": "Maple Ridge", + "_PhysicalAddressProvince": "British Columbia", + "_PhysicalAddressCountry": "Canada", + "_PhysicalAddressPostalCode": "V2X 6G2", + "_SigningAuthorityName": "VelangTest", + "_signatoryTitle1": "Chief Executive Officer (CEO)", + "_SigningAuthorityEmail": "VelangTest@VelangTest.com", + "_SigningAuthorityPhoneNumberPrimary": "(987) 564-3211", + "_SigningAuthorityPhoneNumberSecondary": "", + "simplefile": [], + "previous7": false, + "next8": true, + "_projectTitle": "Updated Project Title", + "pleaseBrieflyDescribeYourProject": "This project aims to develop sustainable resource extraction infrastructure in the Maple Ridge area, supporting local employment, improving community access to economic opportunities, and ensuring environmentally responsible operations in alignment with provincial standards.", + "projectLocationDetailed": { + "location": "Maple Ridge", + "place_name": "", + "community": "Maple Ridge", + "regional_district": "Metro Vancouver", + "economic_region": "Mainland/Southwest", + "rural_category": "Urban 1" + }, + "dataExtractEconomicRegion": "Mainland/Southwest", + "dataExtractRegionalDistrict": "Metro Vancouver", + "dataExtractCommunity": "Maple Ridge", + "dataExtractPlace": "", + "_EconomicRegionHidden": "Mainland/Southwest", + "_RegionalDistrictHidden": "Metro Vancouver", + "_CommunityHidden": "Maple Ridge", + "_placeHidden": "", + "_electoralDistrict": "Maple Ridge-Pitt Meadows", + "willOtherCommunitiesInBritishColumbiaDirectlyBenefit": "no", + "_Community1LocationHidden": "", + "_Community2LocationHidden": "", + "_Community3LocationHidden": "", + "hiddenComponentOtherCommunities": "", + "pleaseTellUsAboutTheCommunities": "Maple Ridge is a growing urban community in the Metro Vancouver Regional District with a population of approximately 80,000 residents. The community has an active local economy with significant opportunities in the resource sector, and residents would directly benefit from increased employment, improved local infrastructure, and expanded economic activity generated by this project.", + "whatCommunityNeedAreYouTryingToAddress": "The Maple Ridge community faces a need for sustainable economic diversification and skilled job creation in the resource sector. Current infrastructure limitations restrict the community's ability to attract investment and support local workers, resulting in residents commuting to other regions for employment opportunities in the oil and gas extraction industry.", + "whatAreTheIntendedOutcomesOfTheProject": "The project intends to: (1) Create a minimum of 25 full-time skilled positions for local residents; (2) Establish compliant and environmentally responsible extraction operations; (3) Contribute to the local tax base, supporting municipal services; (4) Develop partnerships with Indigenous communities and local suppliers; and (5) Deliver measurable reductions in operational emissions through modern equipment and practices.", + "keyProjectActivities": "1. Site assessment and environmental impact studies (April–May 2026); 2. Procurement of equipment and contractor engagement (May–June 2026); 3. Infrastructure construction and installation (June–September 2026); 4. Hiring and training of local workforce (August–October 2026); 5. Commissioning and operational launch (November 2026); 6. Ongoing monitoring, reporting, and community engagement (November–December 2026).", + "_indigenousOwned": "False", + "_forestryOrNon-Forestry": "NON_FORESTRY", + "_acquisition": "YES", + "previous1": false, + "next2": true, + "_ProjectStartDate": "2026-04-01T00:00:00-07:00", + "_ProjectEndDate": "2026-12-31T00:00:00-08:00", + "previous2": false, + "next3": true, + "source1FundingDescription": "Federal Government", + "source2FundingDescription": "Economic Trust", + "previous5": false, + "next5": true, + "iHaveReadTheAttestationAboveAndAgreetoAllTermsTherein": true, + "iAmAuthorizedToSubmitThisApplication": true, + "submit": true, + "previous4": false, + "lateEntry": false, + "subsector": { + "SubSectorCode": "211", + "SubSectorName": "Oil and gas extraction" + }, + "_communityPopulation": 80000, + "_approxNumberOfEmployees": 85, + "_totalProjectCost": 800000, + "_fundingRequest": 500000, + "source1AmountOfFunding": 80000, + "source2AmountOfFunding": 98000 + }, + "metadata": { + "selectData": { + "_MailingAddressProvince": { + "label": "British Columbia" + }, + "_MailingAddressCountry": { + "label": "Canada" + }, + "_PhysicalAddressProvince": { + "label": "British Columbia" + }, + "_PhysicalAddressCountry": { + "label": "Canada" + }, + "_OrganizationType": { + "label": "Corporation" + }, + "_OrgBookStatus": { + "label": "Historical" + }, + "_riskRanking": { + "label": "Medium" + }, + "_signatoryTitle1": { + "label": "Chief Executive Officer (CEO)" + }, + "_electoralDistrict": { + "label": "Maple Ridge-Pitt Meadows" + }, + "_indigenousOwned": { + "label": "No" + }, + "_forestryOrNon-Forestry": { + "label": "Non-Forestry" + }, + "_acquisition": { + "label": "Yes" + }, + "source1FundingDescription": { + "label": "Federal Government" + }, + "source2FundingDescription": { + "label": "Economic Trust" + } + }, + "timezone": "America/Vancouver", + "offset": -480, + "origin": "https://chefs-test.apps.silver.devops.gov.bc.ca", + "referrer": "https://chefs-test.apps.silver.devops.gov.bc.ca/app/form/submit?f=f2f45aa7-62c5-49ca-8846-b214e02adb46", + "browserName": "Netscape", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", + "pathName": "/app/form/submit", + "onLine": true + }, + "state": "submitted", + "_vnote": "" + } +} \ No newline at end of file diff --git a/applications/Unity.AutoUI/cypress/scripts/chefs-submission-payload.json b/applications/Unity.AutoUI/cypress/scripts/chefs-submission-payload.json new file mode 100644 index 0000000000..eb7a72973e --- /dev/null +++ b/applications/Unity.AutoUI/cypress/scripts/chefs-submission-payload.json @@ -0,0 +1,193 @@ +{ + "draft": false, + "submission": { + "data": { + "next6": false, + "acceptanceOfEligibilityCriteria": true, + "previous6": false, + "next7": true, + "_ApplicantName": "VelangTest2", + "_organizationName": { + "type": "name", + "sub_type": "entity_name", + "value": "DEC DESIGN", + "topic_source_id": "FM0036035", + "topic_type": "registration.registries.ca", + "credential_type": "registration.registries.ca", + "credential_id": "9ed8e4ee-0209-4548-90f5-7a6846bf3d5a", + "score": 63.596394 + }, + "_dateExtractBusinessName": "DEC DESIGN", + "_hiddenOrganizationName": "DEC DESIGN", + "_registeredBusinessNumber": "FM0036035", + "_hiddenOrganizationNumber": "FM0036035", + "_OrganizationName": "VelangTest", + "_OrganizationType": "CORPORATION", + "_OrgBookStatus": "HISTORICAL", + "_riskRanking": "MEDIUM", + "sector": { + "SectorCode": "21", + "SectorName": "Mining, quarrying, and oil and gas extraction", + "SubSectors": [ + { + "SubSectorCode": "211", + "SubSectorName": "Oil and gas extraction" + }, + { + "SubSectorCode": "212", + "SubSectorName": "Mining and quarrying (except oil and gas)" + }, + { + "SubSectorCode": "213", + "SubSectorName": "Support activities for mining, and oil and gas extraction" + }, + { + "SubSectorCode": "0", + "SubSectorName": "Other" + } + ] + }, + "_hiddenSector": "Mining, quarrying, and oil and gas extraction", + "_hiddenSubsector": "Oil and gas extraction", + "_ContactName": "VelangTest", + "_ContactTitle": "VelangTest", + "_ContactEmail": "VelangTest@VelangTest.cVelangTest", + "_ContactPhoneNumberPrimary": "(987) 654-6545", + "_ContactPhoneNumberSecondary": "(321) 321-3213", + "_MailingAddressUnit": "", + "_MailingAddressStreet1": "11995 Haney Place", + "_MailingAddressStreet2": "", + "_MailingAddressCity": "Maple Ridge", + "_MailingAddressProvince": "British Columbia", + "_MailingAddressCountry": "Canada", + "_MailingAddressPostalCode": "V2X 6G2", + "_PhysicalAddressUnit": "", + "_PhysicalAddressStreet1": "11995 Haney Place", + "_PhysicalAddressStreet2": "", + "_PhysicalAddressCity": "Maple Ridge", + "_PhysicalAddressProvince": "British Columbia", + "_PhysicalAddressCountry": "Canada", + "_PhysicalAddressPostalCode": "V2X 6G2", + "_SigningAuthorityName": "VelangTest", + "_signatoryTitle1": "Chief Executive Officer (CEO)", + "_SigningAuthorityEmail": "VelangTest@VelangTest.com", + "_SigningAuthorityPhoneNumberPrimary": "(987) 564-3211", + "_SigningAuthorityPhoneNumberSecondary": "", + "simplefile": [], + "previous7": false, + "next8": true, + "_projectTitle": "Maple Ridge Community Resource Development Initiative", + "pleaseBrieflyDescribeYourProject": "This project aims to develop sustainable resource extraction infrastructure in the Maple Ridge area, supporting local employment, improving community access to economic opportunities, and ensuring environmentally responsible operations in alignment with provincial standards.", + "projectLocationDetailed": { + "location": "Maple Ridge", + "place_name": "", + "community": "Maple Ridge", + "regional_district": "Metro Vancouver", + "economic_region": "Mainland/Southwest", + "rural_category": "Urban 1" + }, + "dataExtractEconomicRegion": "Mainland/Southwest", + "dataExtractRegionalDistrict": "Metro Vancouver", + "dataExtractCommunity": "Maple Ridge", + "dataExtractPlace": "", + "_EconomicRegionHidden": "Mainland/Southwest", + "_RegionalDistrictHidden": "Metro Vancouver", + "_CommunityHidden": "Maple Ridge", + "_placeHidden": "", + "_electoralDistrict": "Maple Ridge-Pitt Meadows", + "willOtherCommunitiesInBritishColumbiaDirectlyBenefit": "no", + "_Community1LocationHidden": "", + "_Community2LocationHidden": "", + "_Community3LocationHidden": "", + "hiddenComponentOtherCommunities": "", + "pleaseTellUsAboutTheCommunities": "Maple Ridge is a growing urban community in the Metro Vancouver Regional District with a population of approximately 80,000 residents. The community has an active local economy with significant opportunities in the resource sector, and residents would directly benefit from increased employment, improved local infrastructure, and expanded economic activity generated by this project.", + "whatCommunityNeedAreYouTryingToAddress": "The Maple Ridge community faces a need for sustainable economic diversification and skilled job creation in the resource sector. Current infrastructure limitations restrict the community's ability to attract investment and support local workers, resulting in residents commuting to other regions for employment opportunities in the oil and gas extraction industry.", + "whatAreTheIntendedOutcomesOfTheProject": "The project intends to: (1) Create a minimum of 25 full-time skilled positions for local residents; (2) Establish compliant and environmentally responsible extraction operations; (3) Contribute to the local tax base, supporting municipal services; (4) Develop partnerships with Indigenous communities and local suppliers; and (5) Deliver measurable reductions in operational emissions through modern equipment and practices.", + "keyProjectActivities": "1. Site assessment and environmental impact studies (April–May 2026); 2. Procurement of equipment and contractor engagement (May–June 2026); 3. Infrastructure construction and installation (June–September 2026); 4. Hiring and training of local workforce (August–October 2026); 5. Commissioning and operational launch (November 2026); 6. Ongoing monitoring, reporting, and community engagement (November–December 2026).", + "_indigenousOwned": "False", + "_forestryOrNon-Forestry": "NON_FORESTRY", + "_acquisition": "YES", + "previous1": false, + "next2": true, + "_ProjectStartDate": "2026-04-01T00:00:00-07:00", + "_ProjectEndDate": "2026-12-31T00:00:00-08:00", + "previous2": false, + "next3": true, + "source1FundingDescription": "Federal Government", + "source2FundingDescription": "Economic Trust", + "previous5": false, + "next5": true, + "iHaveReadTheAttestationAboveAndAgreetoAllTermsTherein": true, + "iAmAuthorizedToSubmitThisApplication": true, + "submit": true, + "previous4": false, + "lateEntry": false, + "subsector": { + "SubSectorCode": "211", + "SubSectorName": "Oil and gas extraction" + }, + "_communityPopulation": 80000, + "_approxNumberOfEmployees": 85, + "_totalProjectCost": 800000, + "_fundingRequest": 500000, + "source1AmountOfFunding": 80000, + "source2AmountOfFunding": 98000 + }, + "metadata": { + "selectData": { + "_MailingAddressProvince": { + "label": "British Columbia" + }, + "_MailingAddressCountry": { + "label": "Canada" + }, + "_PhysicalAddressProvince": { + "label": "British Columbia" + }, + "_PhysicalAddressCountry": { + "label": "Canada" + }, + "_OrganizationType": { + "label": "Corporation" + }, + "_OrgBookStatus": { + "label": "Historical" + }, + "_riskRanking": { + "label": "Medium" + }, + "_signatoryTitle1": { + "label": "Chief Executive Officer (CEO)" + }, + "_electoralDistrict": { + "label": "Maple Ridge-Pitt Meadows" + }, + "_indigenousOwned": { + "label": "No" + }, + "_forestryOrNon-Forestry": { + "label": "Non-Forestry" + }, + "_acquisition": { + "label": "Yes" + }, + "source1FundingDescription": { + "label": "Federal Government" + }, + "source2FundingDescription": { + "label": "Economic Trust" + } + }, + "timezone": "America/Vancouver", + "offset": -480, + "origin": "", + "referrer": "", + "browserName": "Netscape", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", + "pathName": "/app/form/submit", + "onLine": true + }, + "state": "submitted", + "_vnote": "" + } +} \ No newline at end of file diff --git a/applications/Unity.AutoUI/cypress/support/commands.ts b/applications/Unity.AutoUI/cypress/support/commands.ts index 686e928f52..7216c27660 100644 --- a/applications/Unity.AutoUI/cypress/support/commands.ts +++ b/applications/Unity.AutoUI/cypress/support/commands.ts @@ -62,9 +62,9 @@ Cypress.Commands.add("getSubmissionDetail", (key: string) => { return cy .fixture<{ submissionDetails: SubmissionDetail[] }>("submissions.json") .then(({ submissionDetails }) => { - const environment = Cypress.env("environment"); + const environment = Cypress.env("environment")?.toUpperCase(); const submissionDetail = submissionDetails.find( - (detail) => detail.unityEnv === environment, + (detail) => detail.unityEnv.toUpperCase() === environment, ); if (submissionDetail && submissionDetail.hasOwnProperty(key)) { @@ -86,9 +86,9 @@ Cypress.Commands.add("getMetabaseDetail", (key: string) => { return cy .fixture<{ metabaseDetails: MetabaseDetail[] }>("metabase.json") .then(({ metabaseDetails }) => { - const environment = Cypress.env("environment"); + const environment = Cypress.env("environment")?.toUpperCase(); const submissionDetail = metabaseDetails.find( - (detail) => detail.unityEnv === environment, + (detail) => detail.unityEnv.toUpperCase() === environment, ); if (submissionDetail && submissionDetail.hasOwnProperty(key)) { @@ -125,9 +125,9 @@ Cypress.Commands.add("getChefsDetail", (key: string) => { return cy .fixture<{ chefsDetails: chefsDetail[] }>("chefs.json") .then(({ chefsDetails }) => { - const environment = Cypress.env("environment"); + const environment = Cypress.env("environment")?.toUpperCase(); const submissionDetail = chefsDetails.find( - (detail) => detail.unityEnv === environment, + (detail) => detail.unityEnv.toUpperCase() === environment, ); if (submissionDetail && submissionDetail.hasOwnProperty(key)) { @@ -196,3 +196,163 @@ Cypress.Commands.add("clearBrowserCache", () => { }); }); }); + +// ============ Dynamic Submission Fetching ============ + +// Use interfaces from index.d.ts - only define API response wrapper here +interface GrantApplicationResponse { + items: GrantApplication[]; + totalCount: number; +} + +/** + * Fetches a dynamic submission ID (referenceNo) from the API after login. + * Uses session cookies automatically from Cypress. + * Results are sorted by submissionDate descending (latest first) by default. + * + * @param options - Optional filters for selecting submissions + * @returns Chainable containing the referenceNo (e.g., "209BD469") + * + * @example + * // Get latest submission from "Data Seeder" category + * cy.fetchDynamicSubmission({ categoryFilter: 'Data Seeder' }).then((id) => { ... }) + * + * @example + * // Get latest "Submitted" submission + * cy.fetchDynamicSubmission({ statusFilter: ['Submitted'] }).then((id) => { ... }) + * + * @example + * // Get second-latest submission from specific category + * cy.fetchDynamicSubmission({ categoryFilter: 'Data Seeder', index: 1 }).then((id) => { ... }) + * + * Available status values: 'Submitted', 'Under Assessment', 'Approved', 'Closed', 'Deferred' + */ +function fetchGrantApplications(): Cypress.Chainable { + const apiUrl = `${Cypress.env("webapp.url")}api/app/grant-application`; + return cy.getCookie("XSRF-TOKEN").then((xsrfCookie) => { + return cy + .request({ + method: "GET", + url: apiUrl, + qs: { submittedFromDate: "", submittedToDate: "" }, + headers: { + Accept: "application/json, text/javascript, */*; q=0.01", + "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest", + RequestVerificationToken: xsrfCookie?.value || "", + }, + failOnStatusCode: false, + }) + .then((response) => { + if (response.status !== 200) { + throw new Error( + `API request failed with status ${response.status}: ${JSON.stringify(response.body)}` + ); + } + const data = response.body as GrantApplicationResponse; + Cypress.log({ name: "fetch", message: `📋 Fetched ${data.items?.length || 0} applications` }); + return data.items || []; + }); + }); +} + +Cypress.Commands.add( + "fetchDynamicSubmission", + (options: FetchSubmissionOptions = {}) => { + return fetchGrantApplications().then((allApplications) => { + let applications = allApplications; + + Cypress.log({ name: "fetch", message: `📋 Fetched ${applications.length} applications from API` }); + + // Filter by category if specified (e.g., 'Data Seeder') + if (options.categoryFilter) { + applications = applications.filter((app) => + app.category === options.categoryFilter + ); + Cypress.log({ + name: "filter", + message: `📋 Filtered to ${applications.length} applications with category: ${options.categoryFilter}`, + }); + } + + // Filter by status if specified (e.g., 'Submitted', 'Under Assessment', 'Approved') + if (options.statusFilter && options.statusFilter.length > 0) { + applications = applications.filter((app) => + options.statusFilter!.includes(app.status) + ); + Cypress.log({ + name: "filter", + message: `📋 Filtered to ${applications.length} applications with status: ${options.statusFilter.join(", ")}`, + }); + } + + // Filter by max age if specified + if (options.maxAge) { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - options.maxAge); + applications = applications.filter((app) => { + const submissionDate = new Date(app.submissionDate); + return submissionDate >= cutoffDate; + }); + Cypress.log({ + name: "filter", + message: `📋 Filtered to ${applications.length} applications within ${options.maxAge} days`, + }); + } + + if (applications.length === 0) { + throw new Error( + "No applications found matching the specified criteria" + ); + } + + // Sort applications (default: by submissionDate descending for latest first) + const sortBy = options.sortBy || 'submissionDate'; + const sortOrder = options.sortOrder || 'desc'; + applications.sort((a, b) => { + let aVal: number | string; + let bVal: number | string; + + if (sortBy === 'submissionDate') { + aVal = new Date(a.submissionDate).getTime(); + bVal = new Date(b.submissionDate).getTime(); + } else { + aVal = a[sortBy] as number; + bVal = b[sortBy] as number; + } + + if (sortOrder === 'desc') { + return bVal > aVal ? 1 : bVal < aVal ? -1 : 0; + } else { + return aVal > bVal ? 1 : aVal < bVal ? -1 : 0; + } + }); + + // Get the submission at the specified index (default: 0 = first/latest) + const index = options.index || 0; + if (index >= applications.length) { + throw new Error( + `Index ${index} out of range. Only ${applications.length} applications available.` + ); + } + + const selectedApp = applications[index]; + Cypress.log({ + name: "selected", + message: `✅ Selected submission: ${selectedApp.referenceNo} (Status: ${selectedApp.status}, Category: ${selectedApp.category})`, + }); + + return selectedApp.referenceNo; + }); + } +); + +/** + * Fetches all available submissions from the API. + * Useful for selecting a specific submission based on custom criteria. + * + * @returns Chainable containing array of grant applications + */ +Cypress.Commands.add("fetchAllSubmissions", () => { + return fetchGrantApplications(); +}); diff --git a/applications/Unity.AutoUI/cypress/support/e2e.ts b/applications/Unity.AutoUI/cypress/support/e2e.ts index 860eac1b25..0b9acad7b3 100644 --- a/applications/Unity.AutoUI/cypress/support/e2e.ts +++ b/applications/Unity.AutoUI/cypress/support/e2e.ts @@ -8,12 +8,24 @@ import '../support/commands' -// Ignore ResizeObserver loop errors - these are benign browser notifications -// that occur when ResizeObserver callbacks don't complete in a single animation frame +// Ignore common errors that shouldn't fail tests Cypress.on('uncaught:exception', (err) => { + // ResizeObserver loop errors - benign browser notifications if (err.message.includes('ResizeObserver loop')) { return false } - // Return true to fail the test for other errors + // Network errors that can occur during navigation + if (err.message.includes('Network Error') || err.message.includes('net::ERR')) { + return false + } + // Script errors from third-party resources + if (err.message.includes('Script error')) { + return false + } + // Chunk loading errors + if (err.message.includes('Loading chunk') || err.message.includes('ChunkLoadError')) { + return false + } + // Return true to fail tests on unexpected uncaught exceptions return true }) diff --git a/applications/Unity.AutoUI/cypress/support/index.d.ts b/applications/Unity.AutoUI/cypress/support/index.d.ts index ad8fa26a5e..979f84c4a5 100644 --- a/applications/Unity.AutoUI/cypress/support/index.d.ts +++ b/applications/Unity.AutoUI/cypress/support/index.d.ts @@ -6,17 +6,107 @@ // https://on.cypress.io/configuration // *********************************************************** -declare namespace Cypress { - interface Chainable { - login(): void; // Custom command to login to Unity - logout(): void; // Custom command to log out of Unity - getSubmissionDetail(key: string): Chainable; // Custom command to get submission details by key for the current environment. - getMetabaseDetail(key: string): Chainable; // Custom command to get metabase details by key for the current environment. - metabaseLogin(): Chainable; // Custom command to login to Metabase - getChefsDetail(key: string): Chainable; // Custom command to get chefs details by key for the current environment. - chefsLogin(): Chainable; // Custom command to login to Chefs - chefsLogout(): Chainable; // Custom command to log out of Chefs - clearSessionStorage(): Chainable; // Custom command to clear session storage. - clearBrowserCache(): Chainable; // Custom command to clear browser cache. +/** + * Grant Application interface matching actual API response + * from /api/app/grant-application endpoint + */ +interface GrantApplication { + id: string; + /** The submission reference number displayed in UI (e.g., "209BD469") */ + referenceNo: string; + /** Application status (e.g., "Submitted", "Approved", "Under Assessment", "Closed", "Deferred") */ + status: string; + /** ISO date string of submission */ + submissionDate: string; + /** Project name */ + projectName: string; + /** Organization name */ + organizationName: string; + /** Requested funding amount */ + requestedAmount: number; + /** Approved funding amount */ + approvedAmount: number; + /** Category/program name */ + category: string; + /** City */ + city: string; + /** Additional fields from API */ + [key: string]: unknown; +} + +/** + * Options for fetching dynamic submissions + */ +interface FetchSubmissionOptions { + /** Filter by status values (e.g., ['Submitted', 'Under Assessment']) */ + statusFilter?: string[]; + /** Filter by category/program name (e.g., 'Data Seeder') */ + categoryFilter?: string; + /** Max age in days (default: no limit) */ + maxAge?: number; + /** Sort by field (default: 'submissionDate') */ + sortBy?: 'submissionDate' | 'requestedAmount' | 'approvedAmount'; + /** Sort order (default: 'desc' for latest first) */ + sortOrder?: 'asc' | 'desc'; + /** Which submission to return after sorting (default: 0 = first/latest) */ + index?: number; +} + +declare namespace Cypress { + interface Chainable { + /** Custom command to login to Unity */ + login(): void; + + /** Custom command to log out of Unity */ + logout(): void; + + /** Custom command to get submission details by key for the current environment */ + getSubmissionDetail(key: string): Chainable; + + /** Custom command to get metabase details by key for the current environment */ + getMetabaseDetail(key: string): Chainable; + + /** Custom command to login to Metabase */ + metabaseLogin(): Chainable; + + /** Custom command to get chefs details by key for the current environment */ + getChefsDetail(key: string): Chainable; + + /** Custom command to login to Chefs */ + chefsLogin(): Chainable; + + /** Custom command to log out of Chefs */ + chefsLogout(): Chainable; + + /** Custom command to clear session storage */ + clearSessionStorage(): Chainable; + + /** Custom command to clear browser cache */ + clearBrowserCache(): Chainable; + + /** + * Fetches a dynamic submission ID from the API after login. + * Uses session cookies automatically from Cypress. + * + * @param options - Optional filters for selecting submissions + * @returns Chainable containing the confirmation ID + * + * @example + * // Get first available submission + * cy.fetchDynamicSubmission().then((id) => { ... }) + * + * @example + * // Get second submission with specific status + * cy.fetchDynamicSubmission({ statusFilter: ['SUBMITTED'], index: 1 }).then((id) => { ... }) + */ + fetchDynamicSubmission(options?: FetchSubmissionOptions): Chainable; + + /** + * Fetches all available submissions from the API. + * Useful for selecting a specific submission based on custom criteria. + * + * @returns Chainable containing array of grant applications + */ + fetchAllSubmissions(): Chainable; } -} \ No newline at end of file +} diff --git a/applications/Unity.AutoUI/package.json b/applications/Unity.AutoUI/package.json index 380421fbe4..76aa67a0f5 100644 --- a/applications/Unity.AutoUI/package.json +++ b/applications/Unity.AutoUI/package.json @@ -1,9 +1,19 @@ { + "scripts": { + "test": "env -u ELECTRON_RUN_AS_NODE cypress run --spec 'cypress/e2e/**/*.cy.ts' --browser chrome", + "test:e2e": "env -u ELECTRON_RUN_AS_NODE cypress run --spec 'cypress/e2e/**/*.cy.ts' --browser chrome", + "test:regression-headed": "env -u ELECTRON_RUN_AS_NODE cypress run --spec 'cypress/regression/**/*.cy.ts' --headed --browser chrome", + "test:regression-headless": "env -u ELECTRON_RUN_AS_NODE cypress run --spec 'cypress/regression/**/*.cy.ts' --headless --browser chrome", + "test:open": "env -u ELECTRON_RUN_AS_NODE cypress open --browser chrome", + "test:seed": "env -u ELECTRON_RUN_AS_NODE cypress run --spec 'cypress/scripts/chefs-api-submission.cy.ts' --browser chrome", + "test:approval-flow": "npm run test:seed && env -u ELECTRON_RUN_AS_NODE cypress run --spec 'cypress/regression/ApprovalFlow.cy.ts' --headless --browser chrome" + }, "dependencies": { "form-data": "^4.0.5", "typescript": "^5.8.3" }, "devDependencies": { - "cypress": "^15.8.1" + "@types/node": "^25.4.0", + "cypress": "15.12.0" } } diff --git a/applications/Unity.AutoUI/tsconfig.json b/applications/Unity.AutoUI/tsconfig.json index 89a0c8e415..75780a90bf 100644 --- a/applications/Unity.AutoUI/tsconfig.json +++ b/applications/Unity.AutoUI/tsconfig.json @@ -106,5 +106,11 @@ "skipLibCheck": true /* Skip type checking all .d.ts files. */ }, // Included or excluded files or folders... https://www.typescriptlang.org/tsconfig - "include": ["cypress/support/**/*.ts", "cypress/e2e/**/*.ts", "cypress/pages/**/*.ts"] + "include": [ + "cypress/support/**/*.ts", + "cypress/e2e/**/*.ts", + "cypress/pages/**/*.ts", + "cypress/regression/**/*.ts", + "cypress/scripts/**/*.ts" + ] } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs index 0fb17d0f99..fc73e7f825 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs @@ -61,7 +61,7 @@ join application in applicationsQuery on submission.ApplicationId equals applica dto.Payments.AddRange(paymentDetails.Select(p => new PaymentInfoItemDto { Id = p.Id, - PaymentNumber = p.PaymentNumber ?? string.Empty, + PaymentNumber = p.InvoiceNumber, ReferenceNo = applicationLookup.TryGetValue(p.CorrelationId, out var refNo) ? refNo : string.Empty, Amount = p.Amount, PaymentDate = p.PaymentDate, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs index e4f4129abe..3958d29de3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs @@ -47,7 +47,6 @@ public async Task SeedAsync(DataSeedContext context) await SeedApplicationStatusAsync(); await SeedAiScoringPersonAsync(context.TenantId); - await SeedBackgroundJobUserAsync(context.TenantId); } @@ -119,43 +118,4 @@ await userRepository.InsertAsync( } } } - - private async Task SeedBackgroundJobUserAsync(System.Guid? tenantId) - { - // Ensure we're in the correct tenant context - using (currentTenant.Change(tenantId)) - { - // Check if the IdentityUser already exists - var existingUser = await userRepository.FindAsync(BackgroundJobConstants.BackgroundJobPersonId); - if (existingUser == null) - { - // Create the IdentityUser in the tenant context - await userRepository.InsertAsync( - new IdentityUser( - BackgroundJobConstants.BackgroundJobPersonId, - BackgroundJobConstants.BackgroundJobUserName, - BackgroundJobConstants.BackgroundJobEmail, - tenantId) - { - Name = BackgroundJobConstants.BackgroundJobName - }, - autoSave: true); - } - - // Check if the Person record already exists - var existingPerson = await personRepository.FirstOrDefaultAsync(p => p.Id == BackgroundJobConstants.BackgroundJobPersonId); - if (existingPerson == null) - { - await personRepository.InsertAsync(new Person - { - Id = BackgroundJobConstants.BackgroundJobPersonId, - OidcSub = BackgroundJobConstants.BackgroundJobOidcSub, - OidcDisplayName = BackgroundJobConstants.BackgroundJobDisplayName, - FullName = BackgroundJobConstants.BackgroundJobDisplayName, - Badge = BackgroundJobConstants.BackgroundJobBadge, - TenantId = tenantId - }); - } - } - } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/EfCoreAuditLogRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/EfCoreAuditLogRepository.cs index a9d3225bce..2a95ae26c9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/EfCoreAuditLogRepository.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/EfCoreAuditLogRepository.cs @@ -1,15 +1,16 @@ +using Microsoft.EntityFrameworkCore; using System; using System.Collections.Generic; -using System.Threading.Tasks; +using System.Linq; using System.Threading; -using Volo.Abp.EntityFrameworkCore; +using System.Threading.Tasks; using Unity.GrantManager.Applications; +using Unity.Modules.Shared.Constants; using Volo.Abp.AuditLogging; using Volo.Abp.AuditLogging.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore; -using System.Linq; -using Volo.Abp.Identity; using Volo.Abp.DependencyInjection; +using Volo.Abp.EntityFrameworkCore; +using Volo.Abp.Identity; namespace Unity.GrantManager.Repositories { @@ -53,7 +54,12 @@ public virtual async Task> GetEntityChangeByTypeW private async Task ResolveUsername(Guid userId) { - var user = await identityUserRepository.GetAsync(userId); + if(userId == BackgroundJobConstants.BackgroundJobPersonId) + { + return $"{BackgroundJobConstants.BackgroundJobName}"; + } + + var user = await identityUserRepository.GetAsync(userId); return user != null ? $"{user.Name} {user.Surname}" : string.Empty; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/Default.cshtml index 520f6f2f0a..03873fff76 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/Default.cshtml @@ -130,12 +130,4 @@ - - + \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/Default.js index feacee5edb..44e7a40851 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/PaymentConfiguration/Default.js @@ -266,6 +266,32 @@ } } - $('.unity-currency-input') //Required for initial masking - .maskMoney({thousands: ',',decimal: '.',}).maskMoney('mask'); + // On load: Apply mask only if field has a value + $('.unity-currency-input').each(function() { + const $field = $(this); + let value = $field.val(); + if (value && value.trim() !== '') { + value = Number.parseFloat(value).toFixed(2); + $field.val(value); + $field.maskMoney({ allowZero: true }).maskMoney('mask'); + } + }); + + // On focus: Remove mask to allow empty values + $('.unity-currency-input').on('focus', function() { + $(this).maskMoney('destroy'); + }); + + // On leave: Apply mask only if there's a value + $('.unity-currency-input').on('blur', function() { + const $field = $(this); + let value = $field.val(); + + if (value && value !== '') { + value = (Math.round(value * 100) / 100).toFixed(2); + $field.val(value); + // Call twice, one to re-initalize on a destroyed field, second to mask + $field.maskMoney({ allowZero: true }).maskMoney('mask'); + } + }); }); diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/PaymentInfoDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/PaymentInfoDataProviderTests.cs index 33d09b85d2..c3429dc54e 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/PaymentInfoDataProviderTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/PaymentInfoDataProviderTests.cs @@ -84,12 +84,12 @@ private static Application CreateApplication(Guid id, string referenceNo = "") return entity; } - private static PaymentRequest CreatePaymentRequest(Guid correlationId, decimal amount = 1000m) + private static PaymentRequest CreatePaymentRequest(Guid correlationId, decimal amount = 1000m, string invoiceNumber = "INV-001") { var siteId = Guid.NewGuid(); var dto = new CreatePaymentRequestDto { - InvoiceNumber = "INV-001", + InvoiceNumber = invoiceNumber, Amount = amount, PayeeName = "Test Payee", ContractNumber = "C-001", @@ -169,7 +169,6 @@ public async Task GetDataAsync_ShouldMapPaymentFields() var applicationId = Guid.NewGuid(); var payment = CreatePaymentRequest(applicationId, 5000m); - payment.SetPaymentNumber("PAY-100"); payment.SetPaymentDate("15-Jan-2025"); payment.SetPaymentRequestStatus(PaymentRequestStatus.Paid); @@ -184,7 +183,7 @@ public async Task GetDataAsync_ShouldMapPaymentFields() dto.Payments.Count.ShouldBe(1); var item = dto.Payments[0]; - item.PaymentNumber.ShouldBe("PAY-100"); + item.PaymentNumber.ShouldBe("INV-001"); item.ReferenceNo.ShouldBe("REF-001"); item.Amount.ShouldBe(5000m); item.PaymentDate.ShouldBe("2025-01-15"); @@ -277,12 +276,12 @@ public async Task GetDataAsync_ShouldNotReturnPaymentsForOtherSubjects() } [Fact] - public async Task GetDataAsync_ShouldHandleNullPaymentNumber() + public async Task GetDataAsync_ShouldHandleEmptyInvoiceNumber() { var request = CreateRequest(); var applicationId = Guid.NewGuid(); - var payment = CreatePaymentRequest(applicationId); + var payment = CreatePaymentRequest(applicationId, invoiceNumber: string.Empty); SetupQueryables( [CreateSubmission(applicationId, "TESTUSER")], diff --git a/documentation/applicant-portal/applicant-profile-data-providers.md b/documentation/applicant-portal/applicant-profile-data-providers.md index 135d63b9f2..91d5d552c3 100644 --- a/documentation/applicant-portal/applicant-profile-data-providers.md +++ b/documentation/applicant-portal/applicant-profile-data-providers.md @@ -428,7 +428,7 @@ flowchart LR | DTO Field | Source | Type | Description | |-----------|--------|------|-------------| | `Id` | `PaymentRequest.Id` | `Guid` | Payment request identifier | -| `PaymentNumber` | `PaymentRequest.PaymentNumber` | `string` | CAS payment number (empty string if null) | +| `PaymentNumber` | `PaymentRequest.InvoiceNumber` | `string` | CAS invoice number (empty string if null) | | `ReferenceNo` | `Application.ReferenceNo` | `string` | Application reference number, resolved via `CorrelationId → Application` lookup | | `Amount` | `PaymentRequest.Amount` | `decimal` | Requested payment amount | | `PaymentDate` | `PaymentRequest.PaymentDate` | `string?` | Date string populated during CAS reconciliation |