diff --git a/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts b/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts index 886fecb2f..6d3cdd6ea 100644 --- a/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts @@ -1,120 +1,81 @@ /// -// cypress/e2e/chefsdata.cy.ts +import { loginIfNeeded } from "../support/auth"; -describe('Unity Login and check data from CHEFS', () => { - const STANDARD_TIMEOUT = 20000 +describe("Unity Login and check data from CHEFS", () => { + const STANDARD_TIMEOUT = 20000; function switchToDefaultGrantsProgramIfAvailable() { - cy.get('body').then(($body) => { - const hasUserInitials = $body.find('.unity-user-initials').length > 0 + cy.get("body").then(($body) => { + const hasUserInitials = $body.find(".unity-user-initials").length > 0; if (!hasUserInitials) { - cy.log('Skipping tenant switch: no user initials menu found') - return + cy.log("Skipping tenant switch: no user initials menu found"); + return; } - cy.get('.unity-user-initials').click() + cy.get(".unity-user-initials").click(); - cy.get('body').then(($body2) => { - const switchLink = $body2.find('#user-dropdown a.dropdown-item').filter((_, el) => { - return (el.textContent || '').trim() === 'Switch Grant Programs' - }) + cy.get("body").then(($body2) => { + const switchLink = $body2 + .find("#user-dropdown a.dropdown-item") + .filter((_, el) => { + return (el.textContent || "").trim() === "Switch Grant Programs"; + }); if (switchLink.length === 0) { - cy.log('Skipping tenant switch: "Switch Grant Programs" not present for this user/session') - cy.get('body').click(0, 0) - return + cy.log( + 'Skipping tenant switch: "Switch Grant Programs" not present for this user/session', + ); + cy.get("body").click(0, 0); + return; } - cy.wrap(switchLink.first()).click() + cy.wrap(switchLink.first()).click(); - cy.url({ timeout: STANDARD_TIMEOUT }).should('include', '/GrantPrograms') + cy.url({ timeout: STANDARD_TIMEOUT }).should( + "include", + "/GrantPrograms", + ); - cy.get('#search-grant-programs', { timeout: STANDARD_TIMEOUT }) - .should('be.visible') + cy.get("#search-grant-programs", { timeout: STANDARD_TIMEOUT }) + .should("be.visible") .clear() - .type('Default Grants Program') + .type("Default Grants Program"); // Flatten nested `within` usage to satisfy S2004 (limit nesting depth) - cy.contains('#UserGrantProgramsTable tbody tr', 'Default Grants Program', { timeout: STANDARD_TIMEOUT }) - .should('exist') + cy.contains( + "#UserGrantProgramsTable tbody tr", + "Default Grants Program", + { timeout: STANDARD_TIMEOUT }, + ) + .should("exist") .within(() => { - cy.contains('button', 'Select') - .should('be.enabled') - .click() - }) + cy.contains("button", "Select").should("be.enabled").click(); + }); - cy.location('pathname', { timeout: STANDARD_TIMEOUT }).should((p) => { - expect(p.indexOf('/GrantApplications') >= 0 || p.indexOf('/auth/') >= 0).to.eq(true) - }) - }) - }) + cy.location("pathname", { timeout: STANDARD_TIMEOUT }).should((p) => { + expect( + p.indexOf("/GrantApplications") >= 0 || p.indexOf("/auth/") >= 0, + ).to.eq(true); + }); + }); + }); } - // TEST renders the Submission tab inside an open shadow root (Form.io). // Enabling this makes cy.get / cy.contains pierce shadow DOM consistently across envs. before(() => { - Cypress.config('includeShadowDom', true) - }) - - it('Verify Login', () => { - // 1.) Always start from the base URL - cy.visit(Cypress.env('webapp.url')) - - // 2.) Decide auth path based on visible UI - cy.get('body', { timeout: STANDARD_TIMEOUT }).then(($body) => { - // Already authenticated - if ($body.find('button:contains("VIEW APPLICATIONS")').length > 0) { - cy.contains('VIEW APPLICATIONS', { timeout: STANDARD_TIMEOUT }).click({ force: true }) - return - } - - // Not authenticated - if ($body.find('button:contains("LOGIN")').length > 0) { - cy.contains('LOGIN', { timeout: STANDARD_TIMEOUT }).should('exist').click({ force: true }) - cy.contains('IDIR', { timeout: STANDARD_TIMEOUT }).should('exist').click({ force: true }) - - cy.get('body', { timeout: STANDARD_TIMEOUT }).then(($loginBody) => { - // Perform IDIR login only if prompted - if ($loginBody.find('#user').length > 0) { - cy.get('#user', { timeout: STANDARD_TIMEOUT }).type(Cypress.env('test1username')) - cy.get('#password', { timeout: STANDARD_TIMEOUT }).type(Cypress.env('test1password')) - cy.contains('Continue', { timeout: STANDARD_TIMEOUT }).should('exist').click({ force: true }) - } else { - cy.log('Already logged in') - } - }) - - return - } - - // Fail loudly if neither state is detectable - throw new Error('Unable to determine authentication state') - }) - - // 3.) Post-condition check - cy.url({ timeout: STANDARD_TIMEOUT }).should('include', '/GrantApplications') - }) - - it('Switch to Default Grants Program if available', () => { - switchToDefaultGrantsProgramIfAvailable() - }) - - it('Handle IDIR if required', () => { - cy.get('body').then(($body) => { - if ($body.find('#social-idir').length > 0) { - cy.get('#social-idir').should('be.visible').click() - } - }) - - cy.location('pathname', { timeout: 30000 }).should('include', '/GrantApplications') - }) + Cypress.config("includeShadowDom", true); + loginIfNeeded({ timeout: STANDARD_TIMEOUT }); + }); - it('Tests the existence and functionality of the Submitted Date From and Submitted Date To filters', () => { + it("Switch to Default Grants Program if available", () => { + switchToDefaultGrantsProgramIfAvailable(); + }); - const pad2 = (n: number) => String(n).padStart(2, '0'); + it("Tests the existence and functionality of the Submitted Date From and Submitted Date To filters", () => { + const pad2 = (n: number) => String(n).padStart(2, "0"); const todayIsoLocal = () => { const d = new Date(); @@ -123,61 +84,184 @@ describe('Unity Login and check data from CHEFS', () => { const waitForRefresh = () => { // S3923 fix: remove identical branches; assert spinner is hidden when present. - cy.get('div.spinner-grow[role="status"]', { timeout: STANDARD_TIMEOUT }) - .then(($s) => { - cy.wrap($s).should('have.attr', 'style').and('contain', 'display: none'); - }); + cy.get('div.spinner-grow[role="status"]', { + timeout: STANDARD_TIMEOUT, + }).then(($s) => { + cy.wrap($s) + .should("have.attr", "style") + .and("contain", "display: none"); + }); }; // --- Submitted Date From --- - cy.get('input#submittedFromDate', { timeout: STANDARD_TIMEOUT }) + cy.get("input#submittedFromDate", { timeout: STANDARD_TIMEOUT }) .click({ force: true }) .clear({ force: true }) - .type('2022-01-01', { force: true }) - .trigger('change', { force: true }) + .type("2022-01-01", { force: true }) + .trigger("change", { force: true }) .blur({ force: true }) - .should('have.value', '2022-01-01'); + .should("have.value", "2022-01-01"); waitForRefresh(); // --- Submitted Date To --- const today = todayIsoLocal(); - cy.get('input#submittedToDate', { timeout: STANDARD_TIMEOUT }) + cy.get("input#submittedToDate", { timeout: STANDARD_TIMEOUT }) .click({ force: true }) .clear({ force: true }) .type(today, { force: true }) - .trigger('change', { force: true }) + .trigger("change", { force: true }) .blur({ force: true }) - .should('have.value', today); + .should("have.value", today); waitForRefresh(); - }); // With no rows selected verify the visibility of Filter, Export, Save View, and Columns. - it('Verify the action buttons are visible with no rows selected', () => { + it("Verifies the expected action buttons are visible when no rows are selected", () => { + cy.get("#GrantApplicationsTable", { timeout: STANDARD_TIMEOUT }).should( + "exist", + ); + + // Ensure we start from a clean selection state (0 selected) + // (Using same "select all / deselect all" toggle approach as the working 1-row test) + cy.get("div.dt-scroll-head thead input", { timeout: STANDARD_TIMEOUT }) + .should("exist") + .click({ force: true }) + .click({ force: true }); + + cy.get("#GrantApplicationsTable tbody tr.selected", { + timeout: STANDARD_TIMEOUT, + }).should("have.length", 0); + + // Filter button (left action bar group) + cy.get("#btn-toggle-filter", { timeout: STANDARD_TIMEOUT }).should( + "be.visible", + ); + + // Right-side buttons + cy.contains( + "#dynamicButtonContainerId .dt-buttons button span", + "Export", + { timeout: STANDARD_TIMEOUT }, + ).should("be.visible"); + cy.contains("#dynamicButtonContainerId button.grp-savedStates", "Save View", { + timeout: STANDARD_TIMEOUT, + }).should("be.visible"); + cy.contains( + "#dynamicButtonContainerId .dt-buttons button span", + "Columns", + { timeout: STANDARD_TIMEOUT }, + ).should("be.visible"); + + // Optional sanity: action buttons that require selection should be disabled when none selected + cy.get("#externalLink", { timeout: STANDARD_TIMEOUT }).should( + "be.disabled", + ); // Open + cy.get("#assignApplication", { timeout: STANDARD_TIMEOUT }).should( + "be.disabled", + ); // Assign + cy.get("#approveApplications", { timeout: STANDARD_TIMEOUT }).should( + "be.disabled", + ); // Approve + cy.get("#tagApplication", { timeout: STANDARD_TIMEOUT }).should( + "be.disabled", + ); // Tags + cy.get("#applicationPaymentRequest", { + timeout: STANDARD_TIMEOUT, + }).should("be.disabled"); // Payment + cy.get("#applicationLink", { timeout: STANDARD_TIMEOUT }).should( + "be.disabled", + ); // Info + }); - }) + // With one row selected verify the visibility of Open, Assign, Approve, Tags, Payment, Info, Filter, Export, Save View, and Columns. + it("Verifies the expected action buttons are visible when only one row is selected", () => { + cy.get("#GrantApplicationsTable", { timeout: STANDARD_TIMEOUT }).should( + "exist", + ); - // With one row selected verify the visibility of Filter, Export, Save View, and Columns. - it('Verify the action buttons are visible with one row selected', () => { + //Ensure we start from a clean selection state (0 selected) + cy.get("div.dt-scroll-head thead input", { timeout: STANDARD_TIMEOUT }) + .should("exist") + .click({ force: true }) + .click({ force: true }); + + cy.get("#GrantApplicationsTable tbody tr.selected", { + timeout: STANDARD_TIMEOUT, + }).should("have.length", 0); + + // Select exactly 1 row (click a non-link cell, matching your earlier helper logic) + cy.get("#GrantApplicationsTable tbody tr", { timeout: STANDARD_TIMEOUT }) + .should("have.length.greaterThan", 0) + .first() + .find("td") + .not(":has(a)") + .first() + .click({ force: true }); - }) + cy.get("#GrantApplicationsTable tbody tr.selected", { + timeout: STANDARD_TIMEOUT, + }).should("have.length", 1); - it('Clicks Payment and force-closes the modal', () => { + // Action bar (left group) + cy.get("#app_custom_buttons", { timeout: STANDARD_TIMEOUT }) + .should("exist") + .scrollIntoView(); + + // Left-side action buttons (actual IDs on this page) + cy.get("#externalLink", { timeout: STANDARD_TIMEOUT }).should("be.visible"); // Open + cy.get("#assignApplication", { timeout: STANDARD_TIMEOUT }).should( + "be.visible", + ); // Assign + cy.get("#approveApplications", { timeout: STANDARD_TIMEOUT }).should( + "be.visible", + ); // Approve + cy.get("#tagApplication", { timeout: STANDARD_TIMEOUT }).should("be.visible"); // Tags + cy.get("#applicationPaymentRequest", { + timeout: STANDARD_TIMEOUT, + }).should("be.visible"); // Payment + cy.get("#applicationLink", { timeout: STANDARD_TIMEOUT }).should( + "be.visible", + ); // Info + + // Filter button + cy.get("#btn-toggle-filter", { timeout: STANDARD_TIMEOUT }).should( + "be.visible", + ); + + // Right-side buttons + cy.contains( + "#dynamicButtonContainerId .dt-buttons button span", + "Export", + { timeout: STANDARD_TIMEOUT }, + ).should("be.visible"); + cy.contains("#dynamicButtonContainerId button.grp-savedStates", "Save View", { + timeout: STANDARD_TIMEOUT, + }).should("be.visible"); + cy.contains( + "#dynamicButtonContainerId .dt-buttons button span", + "Columns", + { timeout: STANDARD_TIMEOUT }, + ).should("be.visible"); + }); + + it("Verifies the expected action buttons are visible when two rows are selected", () => { const BUTTON_TIMEOUT = 60000; // Ensure table has rows - cy.get('.dt-scroll-body tbody tr', { timeout: STANDARD_TIMEOUT }) - .should('have.length.greaterThan', 1); + cy.get(".dt-scroll-body tbody tr", { timeout: STANDARD_TIMEOUT }).should( + "have.length.greaterThan", + 1, + ); // Select two rows using non-link cells const clickSelectableCell = (rowIdx: number, withCtrl = false) => { - cy.get('.dt-scroll-body tbody tr', { timeout: STANDARD_TIMEOUT }) + cy.get(".dt-scroll-body tbody tr", { timeout: STANDARD_TIMEOUT }) .eq(rowIdx) - .find('td') - .not(':has(a)') + .find("td") + .not(":has(a)") .first() .click({ force: true, ctrlKey: withCtrl }); }; @@ -185,328 +269,379 @@ describe('Unity Login and check data from CHEFS', () => { clickSelectableCell(1, true); // ActionBar - cy.get('#app_custom_buttons', { timeout: STANDARD_TIMEOUT }) - .should('exist') + cy.get("#app_custom_buttons", { timeout: STANDARD_TIMEOUT }) + .should("exist") .scrollIntoView(); // Click Payment - cy.get('#applicationPaymentRequest', { timeout: BUTTON_TIMEOUT }) - .should('be.visible') - .and('not.be.disabled') + cy.get("#applicationPaymentRequest", { timeout: BUTTON_TIMEOUT }) + .should("be.visible") + .and("not.be.disabled") .click({ force: true }); // Wait until modal is shown - cy.get('#payment-modal', { timeout: STANDARD_TIMEOUT }) - .should('be.visible') - .and('have.class', 'show'); + cy.get("#payment-modal", { timeout: STANDARD_TIMEOUT }) + .should("be.visible") + .and("have.class", "show"); // Attempt graceful closes first - cy.get('body').type('{esc}', { force: true }); // Bootstrap listens to ESC - cy.get('.modal-backdrop', { timeout: STANDARD_TIMEOUT }).then(($bd) => { + cy.get("body").type("{esc}", { force: true }); // Bootstrap listens to ESC + cy.get(".modal-backdrop", { timeout: STANDARD_TIMEOUT }).then(($bd) => { if ($bd.length) { - cy.wrap($bd).click('topLeft', { force: true }); + cy.wrap($bd).click("topLeft", { force: true }); } }); // Try footer Cancel if available (avoid .catch on Cypress chainable) - cy.contains('#payment-modal .modal-footer button', 'Cancel', { timeout: STANDARD_TIMEOUT }) - .then(($btn) => { - if ($btn && $btn.length > 0) { - cy.wrap($btn).scrollIntoView().click({ force: true }); - } else { - cy.log('Cancel button not present, proceeding to hard-close fallback'); - } - }); + cy.contains("#payment-modal .modal-footer button", "Cancel", { + timeout: STANDARD_TIMEOUT, + }).then(($btn) => { + if ($btn && $btn.length > 0) { + cy.wrap($btn).scrollIntoView().click({ force: true }); + } else { + cy.log("Cancel button not present, proceeding to hard-close fallback"); + } + }); // Use window API (if present), then hard-close fallback cy.window().then((win: any) => { try { - if (typeof win.closePaymentModal === 'function') { + if (typeof win.closePaymentModal === "function") { win.closePaymentModal(); } - } catch { /* ignore */ } + } catch { + /* ignore */ + } // HARD CLOSE: forcibly hide modal and remove backdrop const $ = (win as any).jQuery || (win as any).$; if ($) { try { - $('#payment-modal') - .removeClass('show') - .attr('aria-hidden', 'true') - .css('display', 'none'); - $('.modal-backdrop').remove(); - $('body').removeClass('modal-open').css('overflow', ''); // restore scroll - } catch { /* ignore */ } + $("#payment-modal") + .removeClass("show") + .attr("aria-hidden", "true") + .css("display", "none"); + $(".modal-backdrop").remove(); + $("body").removeClass("modal-open").css("overflow", ""); // restore scroll + } catch { + /* ignore */ + } } }); // Verify modal/backdrop gone (be tolerant: assert non-interference instead of visibility only) - cy.get('#payment-modal', { timeout: STANDARD_TIMEOUT }).should(($m) => { - const isHidden = !$m.is(':visible') || !$m.hasClass('show'); - expect(isHidden, 'payment-modal hidden or not shown').to.eq(true); + cy.get("#payment-modal", { timeout: STANDARD_TIMEOUT }).should(($m) => { + const isHidden = !$m.is(":visible") || !$m.hasClass("show"); + expect(isHidden, "payment-modal hidden or not shown").to.eq(true); }); - cy.get('.modal-backdrop', { timeout: STANDARD_TIMEOUT }).should('not.exist'); + cy.get(".modal-backdrop", { timeout: STANDARD_TIMEOUT }).should("not.exist"); // Right-side buttons usable - cy.get('#dynamicButtonContainerId', { timeout: STANDARD_TIMEOUT }) - .should('exist') + cy.get("#dynamicButtonContainerId", { timeout: STANDARD_TIMEOUT }) + .should("exist") .scrollIntoView(); - cy.contains('#dynamicButtonContainerId .dt-buttons button span', 'Export', { timeout: STANDARD_TIMEOUT }).should('be.visible'); - cy.contains('#dynamicButtonContainerId button.grp-savedStates', 'Save View', { timeout: STANDARD_TIMEOUT }).should('be.visible'); - cy.contains('#dynamicButtonContainerId .dt-buttons button span', 'Columns', { timeout: STANDARD_TIMEOUT }).should('be.visible'); + cy.contains("#dynamicButtonContainerId .dt-buttons button span", "Export", { + timeout: STANDARD_TIMEOUT, + }).should("be.visible"); + cy.contains( + "#dynamicButtonContainerId button.grp-savedStates", + "Save View", + { timeout: STANDARD_TIMEOUT }, + ).should("be.visible"); + cy.contains( + "#dynamicButtonContainerId .dt-buttons button span", + "Columns", + { timeout: STANDARD_TIMEOUT }, + ).should("be.visible"); }); + // Walk the Columns menu and toggle each column on, verifying the column is visible. + it("Verify all columns in the menu are visible when and toggled on.", () => { + const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - // Walk the Columns menu and toggle each column on, verifying the column is visibile. - it('Verify all columns in the menu are visible when and toggled on.', () => { const clickColumnsItem = (label: string) => { - cy.contains('a.dropdown-item', label, { timeout: STANDARD_TIMEOUT }) - .should('exist') + // Case-insensitive exact match so DEV "ID" and PROD "Id" both work + const re = new RegExp(`^\\s*${escapeRegex(label)}\\s*$`, "i"); + cy.contains("a.dropdown-item", re, { timeout: STANDARD_TIMEOUT }) + .should("exist") .scrollIntoView() - .click({ force: true }) - } + .click({ force: true }); + }; + + const normalize = (s: string) => + (s || "").replace(/\s+/g, " ").trim().toLowerCase(); const getVisibleHeaderTitles = () => { - return cy.get('.dt-scroll-head span.dt-column-title', { timeout: STANDARD_TIMEOUT }).then(($els) => { - const titles = Cypress.$($els) - .toArray() - .map((el) => (el.textContent || '').replace(/\s+/g, ' ').trim()) - .filter((t) => t.length > 0) - return titles - }) - } + return cy + .get(".dt-scroll-head span.dt-column-title", { + timeout: STANDARD_TIMEOUT, + }) + .then(($els) => { + const titles = Cypress.$($els) + .toArray() + .map((el) => (el.textContent || "").replace(/\s+/g, " ").trim()) + .filter((t) => t.length > 0); + return titles; + }); + }; const assertVisibleHeadersInclude = (expected: string[]) => { getVisibleHeaderTitles().then((titles) => { + const normTitles = titles.map(normalize); expected.forEach((e) => { - expect(titles, `visible headers should include "${e}"`).to.include(e) - }) - }) - } + const target = normalize(e); + expect( + normTitles, + `visible headers should include "${e}" (case-insensitive)`, + ).to.include(target); + }); + }); + }; const scrollX = (x: number) => { - cy.get('.dt-scroll-body', { timeout: STANDARD_TIMEOUT }) - .should('exist') - .scrollTo(x, 0, { duration: 0, ensureScrollable: false }) - } + cy.get(".dt-scroll-body", { timeout: STANDARD_TIMEOUT }) + .should("exist") + .scrollTo(x, 0, { duration: 0, ensureScrollable: false }); + }; + + // Close any open dropdowns/modals first + cy.get("body").then(($body) => { + if ($body.find(".dt-button-background").length > 0) { + cy.get(".dt-button-background").click({ force: true }); + } + }); // Open the "Save View" dropdown - cy.get('button.grp-savedStates', { timeout: STANDARD_TIMEOUT }) - .should('be.visible') - .and('contain.text', 'Save View') - .click() + cy.get("button.grp-savedStates", { timeout: STANDARD_TIMEOUT }) + .should("be.visible") + .and("contain.text", "Save View") + .click(); // Click "Reset to Default View" - cy.contains('a.dropdown-item', 'Reset to Default View', { timeout: STANDARD_TIMEOUT }) - .should('exist') - .click({ force: true }) + cy.contains("a.dropdown-item", "Reset to Default View", { + timeout: STANDARD_TIMEOUT, + }) + .should("exist") + .click({ force: true }); + + // Wait for table to rebuild after reset - check for default columns + cy.get(".dt-scroll-head span.dt-column-title", { + timeout: STANDARD_TIMEOUT, + }).should("have.length.gt", 5); // Open Columns menu - cy.contains('span', 'Columns', { timeout: STANDARD_TIMEOUT }) - .should('be.visible') - .click() - - clickColumnsItem('% of Total Project Budget') - clickColumnsItem('Acquisition') - clickColumnsItem('Applicant Electoral District') - - clickColumnsItem('Applicant Id') - clickColumnsItem('Applicant Id') - - clickColumnsItem('Applicant Name') - clickColumnsItem('Applicant Name') - - clickColumnsItem('Approved Amount') - clickColumnsItem('Approved Amount') - - clickColumnsItem('Assessment Result') - - clickColumnsItem('Assignee') - clickColumnsItem('Assignee') - - clickColumnsItem('Business Number') - - clickColumnsItem('Category') - clickColumnsItem('Category') - - clickColumnsItem('City') - - clickColumnsItem('Community') - clickColumnsItem('Community') - - clickColumnsItem('Community Population') - clickColumnsItem('Contact Business Phone') - clickColumnsItem('Contact Cell Phone') - clickColumnsItem('Contact Email') - clickColumnsItem('Contact Full Name') - clickColumnsItem('Contact Title') - clickColumnsItem('Decision Date') - clickColumnsItem('Decline Rationale') - clickColumnsItem('Due Date') - clickColumnsItem('Due Diligence Status') - clickColumnsItem('Economic Region') - clickColumnsItem('Forestry Focus') - clickColumnsItem('Forestry or Non-Forestry') - clickColumnsItem('FYE Day') - clickColumnsItem('FYE Month') - clickColumnsItem('Indigenous') - clickColumnsItem('Likelihood of Funding') - clickColumnsItem('Non-Registered Organization Name') - clickColumnsItem('Notes') - clickColumnsItem('Org Book Status') - clickColumnsItem('Organization Type') - clickColumnsItem('Other Sector/Sub/Industry Description') - clickColumnsItem('Owner') - clickColumnsItem('Payout') - clickColumnsItem('Place') - clickColumnsItem('Project Electoral District') - clickColumnsItem('Project End Date') - - clickColumnsItem('Project Name') - clickColumnsItem('Project Name') - - clickColumnsItem('Project Start Date') - clickColumnsItem('Project Summary') - clickColumnsItem('Projected Funding Total') - clickColumnsItem('Recommended Amount') - clickColumnsItem('Red-Stop') - clickColumnsItem('Regional District') - clickColumnsItem('Registered Organization Name') - clickColumnsItem('Registered Organization Number') - - clickColumnsItem('Requested Amount') - clickColumnsItem('Requested Amount') - - clickColumnsItem('Risk Ranking') - clickColumnsItem('Sector') - clickColumnsItem('Signing Authority Business Phone') - clickColumnsItem('Signing Authority Cell Phone') - clickColumnsItem('Signing Authority Email') - clickColumnsItem('Signing Authority Full Name') - clickColumnsItem('Signing Authority Title') - - clickColumnsItem('Status') - clickColumnsItem('Status') - - clickColumnsItem('Sub-Status') - clickColumnsItem('Sub-Status') - - clickColumnsItem('Submission #') - clickColumnsItem('Submission #') - - clickColumnsItem('Submission Date') - clickColumnsItem('Submission Date') - - clickColumnsItem('SubSector') - - clickColumnsItem('Tags') - clickColumnsItem('Tags') - - clickColumnsItem('Total Paid Amount $') - clickColumnsItem('Total Project Budget') - clickColumnsItem('Total Score') - clickColumnsItem('Unity Application Id') + cy.contains("span", "Columns", { timeout: STANDARD_TIMEOUT }) + .should("be.visible") + .click(); + + // Wait for columns dropdown to be fully populated + cy.get("a.dropdown-item", { timeout: STANDARD_TIMEOUT }).should( + "have.length.gt", + 50, + ); + + clickColumnsItem("% of Total Project Budget"); + clickColumnsItem("Acquisition"); + clickColumnsItem("Applicant Electoral District"); + + clickColumnsItem("Applicant Id"); + clickColumnsItem("Applicant Id"); + + clickColumnsItem("Applicant Name"); + clickColumnsItem("Applicant Name"); + + clickColumnsItem("Approved Amount"); + clickColumnsItem("Approved Amount"); + + clickColumnsItem("Assessment Result"); + + clickColumnsItem("Assignee"); + clickColumnsItem("Assignee"); + + clickColumnsItem("Business Number"); + + clickColumnsItem("Category"); + clickColumnsItem("Category"); + + clickColumnsItem("City"); + + clickColumnsItem("Community"); + clickColumnsItem("Community"); + + clickColumnsItem("Community Population"); + clickColumnsItem("Contact Business Phone"); + clickColumnsItem("Contact Cell Phone"); + clickColumnsItem("Contact Email"); + clickColumnsItem("Contact Full Name"); + clickColumnsItem("Contact Title"); + clickColumnsItem("Decision Date"); + clickColumnsItem("Decline Rationale"); + clickColumnsItem("Due Date"); + clickColumnsItem("Due Diligence Status"); + clickColumnsItem("Economic Region"); + clickColumnsItem("Forestry Focus"); + clickColumnsItem("Forestry or Non-Forestry"); + clickColumnsItem("FYE Day"); + clickColumnsItem("FYE Month"); + clickColumnsItem("Indigenous"); + clickColumnsItem("Likelihood of Funding"); + clickColumnsItem("Non-Registered Organization Name"); + clickColumnsItem("Notes"); + clickColumnsItem("Org Book Status"); + clickColumnsItem("Organization Type"); + clickColumnsItem("Other Sector/Sub/Industry Description"); + clickColumnsItem("Owner"); + clickColumnsItem("Payout"); + clickColumnsItem("Place"); + clickColumnsItem("Project Electoral District"); + clickColumnsItem("Project End Date"); + + clickColumnsItem("Project Name"); + clickColumnsItem("Project Name"); + + clickColumnsItem("Project Start Date"); + clickColumnsItem("Project Summary"); + clickColumnsItem("Projected Funding Total"); + clickColumnsItem("Recommended Amount"); + clickColumnsItem("Red-Stop"); + clickColumnsItem("Regional District"); + clickColumnsItem("Registered Organization Name"); + clickColumnsItem("Registered Organization Number"); + + clickColumnsItem("Requested Amount"); + clickColumnsItem("Requested Amount"); + + clickColumnsItem("Risk Ranking"); + clickColumnsItem("Sector"); + clickColumnsItem("Signing Authority Business Phone"); + clickColumnsItem("Signing Authority Cell Phone"); + clickColumnsItem("Signing Authority Email"); + clickColumnsItem("Signing Authority Full Name"); + clickColumnsItem("Signing Authority Title"); + + clickColumnsItem("Status"); + clickColumnsItem("Status"); + + clickColumnsItem("Sub-Status"); + clickColumnsItem("Sub-Status"); + + clickColumnsItem("Submission #"); + clickColumnsItem("Submission #"); + + clickColumnsItem("Submission Date"); + clickColumnsItem("Submission Date"); + + clickColumnsItem("SubSector"); + + clickColumnsItem("Tags"); + clickColumnsItem("Tags"); + + clickColumnsItem("Total Paid Amount $"); + clickColumnsItem("Total Project Budget"); + clickColumnsItem("Total Score"); + clickColumnsItem("Unity Application Id"); // Close the menu and wait until the overlay is gone - cy.get('div.dt-button-background', { timeout: STANDARD_TIMEOUT }) - .should('exist') - .click({ force: true }) + cy.get("div.dt-button-background", { timeout: STANDARD_TIMEOUT }) + .should("exist") + .click({ force: true }); - cy.get('div.dt-button-background', { timeout: STANDARD_TIMEOUT }).should('not.exist') + cy.get("div.dt-button-background", { timeout: STANDARD_TIMEOUT }).should( + "not.exist", + ); // Assertions by horizontal scroll segments (human-style scan) - scrollX(0) + scrollX(0); assertVisibleHeadersInclude([ - 'Applicant Name', - 'Category', - 'Submission #', - 'Submission Date', - 'Status', - 'Sub-Status', - 'Community', - 'Requested Amount', - 'Approved Amount', - 'Project Name', - 'Applicant Id', - ]) - - scrollX(1500) + "Applicant Name", + "Category", + "Submission #", + "Submission Date", + "Status", + "Sub-Status", + "Community", + "Requested Amount", + "Approved Amount", + "Project Name", + "Applicant Id", + ]); + + scrollX(1500); assertVisibleHeadersInclude([ - 'Tags', - 'Assignee', - 'SubSector', - 'Economic Region', - 'Regional District', - 'Registered Organization Number', - 'Org Book Status', - ]) - - scrollX(3000) + "Tags", + "Assignee", + "SubSector", + "Economic Region", + "Regional District", + "Registered Organization Number", + "Org Book Status", + ]); + + scrollX(3000); assertVisibleHeadersInclude([ - 'Project Start Date', - 'Project End Date', - 'Projected Funding Total', - 'Total Paid Amount $', - 'Project Electoral District', - 'Applicant Electoral District', - ]) - - scrollX(4500) + "Project Start Date", + "Project End Date", + "Projected Funding Total", + "Total Paid Amount $", + "Project Electoral District", + "Applicant Electoral District", + ]); + + scrollX(4500); assertVisibleHeadersInclude([ - 'Forestry or Non-Forestry', - 'Forestry Focus', - 'Acquisition', - 'City', - 'Community Population', - 'Likelihood of Funding', - 'Total Score', - ]) - - scrollX(6000) + "Forestry or Non-Forestry", + "Forestry Focus", + "Acquisition", + "City", + "Community Population", + "Likelihood of Funding", + "Total Score", + ]); + + scrollX(6000); assertVisibleHeadersInclude([ - 'Assessment Result', - 'Recommended Amount', - 'Due Date', - 'Owner', - 'Decision Date', - 'Project Summary', - 'Organization Type', - 'Business Number', - ]) - - scrollX(7500) + "Assessment Result", + "Recommended Amount", + "Due Date", + "Owner", + "Decision Date", + "Project Summary", + "Organization Type", + "Business Number", + ]); + + scrollX(7500); assertVisibleHeadersInclude([ - 'Due Diligence Status', - 'Decline Rationale', - 'Contact Full Name', - 'Contact Title', - 'Contact Email', - 'Contact Business Phone', - 'Contact Cell Phone', - ]) - - scrollX(9000) + "Due Diligence Status", + "Decline Rationale", + "Contact Full Name", + "Contact Title", + "Contact Email", + "Contact Business Phone", + "Contact Cell Phone", + ]); + + scrollX(9000); assertVisibleHeadersInclude([ - 'Signing Authority Full Name', - 'Signing Authority Title', - 'Signing Authority Email', - 'Signing Authority Business Phone', - 'Signing Authority Cell Phone', - 'Place', - 'Risk Ranking', - 'Notes', - 'Red-Stop', - 'Indigenous', - 'FYE Day', - 'FYE Month', - 'Payout', - 'Unity Application Id', - ]) - }) - - - it('Verify Logout', () => { - cy.logout() - }) -}) + "Signing Authority Full Name", + "Signing Authority Title", + "Signing Authority Email", + "Signing Authority Business Phone", + "Signing Authority Cell Phone", + "Place", + "Risk Ranking", + "Notes", + "Red-Stop", + "Indigenous", + "FYE Day", + "FYE Month", + "Payout", + "Unity Application Id", + ]); + }); + + it("Verify Logout", () => { + cy.logout(); + }); +}); \ No newline at end of file diff --git a/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts b/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts index 0c2c6dc12..152081bae 100644 --- a/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts @@ -1,338 +1,385 @@ // cypress/e2e/basicEmail.cy.ts -describe('Send an email', () => { - const TEST_EMAIL_TO = Cypress.env('TEST_EMAIL_TO') as string - const TEST_EMAIL_CC = Cypress.env('TEST_EMAIL_CC') as string - const TEST_EMAIL_BCC = Cypress.env('TEST_EMAIL_BCC') as string - const TEMPLATE_NAME = 'Test Case 1' - const STANDARD_TIMEOUT = 20000 - - // Only suppress the noisy ResizeObserver error that Unity throws in TEST. - // Everything else should still fail the test. - Cypress.on('uncaught:exception', (err) => { - const msg = err && err.message ? err.message : '' - if (msg.indexOf('ResizeObserver loop limit exceeded') >= 0) { - return false - } - return true - }) - - const now = new Date() - const timestamp = - now.getFullYear() + - '-' + - String(now.getMonth() + 1).padStart(2, '0') + - '-' + - String(now.getDate()).padStart(2, '0') + - ' ' + - String(now.getHours()).padStart(2, '0') + - ':' + - String(now.getMinutes()).padStart(2, '0') + - ':' + - String(now.getSeconds()).padStart(2, '0') - - const TEST_EMAIL_SUBJECT = `Smoke Test Email ${timestamp}` - - function ensureLoggedInToGrantApplications() { - // Headless runs specs sequentially in the same browser process. - // Do not assume logged-out or logged-in. Detect UI state like chefsdata.cy.ts does. - cy.visit(Cypress.env('webapp.url')) - - cy.get('body', { timeout: STANDARD_TIMEOUT }).then(($body) => { - // Already authenticated - if ($body.find('button:contains("VIEW APPLICATIONS")').length > 0) { - cy.contains('VIEW APPLICATIONS', { timeout: STANDARD_TIMEOUT }).click({ force: true }) - return - } - - // Not authenticated - if ($body.find('button:contains("LOGIN")').length > 0) { - cy.contains('LOGIN', { timeout: STANDARD_TIMEOUT }).should('exist').click({ force: true }) - - cy.get('body', { timeout: STANDARD_TIMEOUT }).then(($loginBody) => { - // IDIR chooser may or may not appear - if ($loginBody.find(':contains("IDIR")').length > 0) { - cy.contains('IDIR', { timeout: STANDARD_TIMEOUT }).click({ force: true }) - } - - cy.get('body', { timeout: STANDARD_TIMEOUT }).then(($authBody) => { - // Only type creds if the login form is actually present - if ($authBody.find('#user').length > 0) { - cy.get('#user', { timeout: STANDARD_TIMEOUT }).type(Cypress.env('test1username')) - cy.get('#password', { timeout: STANDARD_TIMEOUT }).type(Cypress.env('test1password')) - cy.contains('Continue', { timeout: STANDARD_TIMEOUT }).click({ force: true }) - } else { - cy.log('Already authenticated') - } - }) - }) - - return - } - - throw new Error('Unable to determine authentication state') - }) - - cy.location('pathname', { timeout: 30000 }).should('include', '/GrantApplications') +describe("Send an email", () => { + const TEST_EMAIL_TO = Cypress.env("TEST_EMAIL_TO") as string; + const TEST_EMAIL_CC = Cypress.env("TEST_EMAIL_CC") as string; + const TEST_EMAIL_BCC = Cypress.env("TEST_EMAIL_BCC") as string; + const TEMPLATE_NAME = "Test Case 1"; + const STANDARD_TIMEOUT = 20000; + + // Only suppress the noisy ResizeObserver error that Unity throws in TEST. + // Everything else should still fail the test. + Cypress.on("uncaught:exception", (err) => { + const msg = err && err.message ? err.message : ""; + if (msg.indexOf("ResizeObserver loop limit exceeded") >= 0) { + return false; } - - function switchToDefaultGrantsProgramIfAvailable() { - cy.get('body').then(($body) => { - const hasUserInitials = $body.find('.unity-user-initials').length > 0 - - if (!hasUserInitials) { - cy.log('Skipping tenant: no user initials menu found') - return - } - - cy.get('.unity-user-initials').click() - - cy.get('body').then(($body2) => { - const switchLink = $body2.find('#user-dropdown a.dropdown-item').filter((_, el) => { - return (el.textContent || '').trim() === 'Switch Grant Programs' - }) - - if (switchLink.length === 0) { - cy.log('Skipping tenant: "Switch Grant Programs" not present for this user/session') - cy.get('body').click(0, 0) - return - } - - cy.wrap(switchLink.first()).click() - - cy.url({ timeout: STANDARD_TIMEOUT }).should('include', '/GrantPrograms') - - cy.get('#search-grant-programs', { timeout: STANDARD_TIMEOUT }) - .should('be.visible') - .clear() - .type('Default Grants Program') - - cy.get('#UserGrantProgramsTable', { timeout: STANDARD_TIMEOUT }) - .should('be.visible') - .within(() => { - cy.contains('tbody tr', 'Default Grants Program', { timeout: STANDARD_TIMEOUT }) - .should('exist') - .within(() => { - cy.contains('button', 'Select') - .should('be.enabled') - .click() - }) - }) - - cy.location('pathname', { timeout: STANDARD_TIMEOUT }).should((p) => { - expect(p.indexOf('/GrantApplications') >= 0 || p.indexOf('/auth/') >= 0).to.eq(true) - }) - }) - }) - } - - function openSavedEmailFromHistoryBySubject(subject: string) { - cy.get('body', { timeout: STANDARD_TIMEOUT }).then(($body) => { - const historyTableById = $body.find('#EmailHistoryTable') - if (historyTableById.length > 0) { - cy.get('#EmailHistoryTable', { timeout: STANDARD_TIMEOUT }) - .should('be.visible') - .within(() => { - cy.contains('td', subject, { timeout: STANDARD_TIMEOUT }) - .should('exist') - .click() - }) - return - } - - cy.contains('td', subject, { timeout: STANDARD_TIMEOUT }) - .should('exist') - .click() - }) - } - - function confirmSendDialogIfPresent() { - cy.get('body', { timeout: STANDARD_TIMEOUT }).should(($b) => { - const hasBootstrapShownModal = $b.find('.modal.show').length > 0 - const hasSwal = $b.find('.swal2-container').length > 0 - const hasConfirmBtn = $b.find('#btn-confirm-send').length > 0 - expect(hasBootstrapShownModal || hasSwal || hasConfirmBtn).to.eq(true) - }) - - cy.get('body', { timeout: STANDARD_TIMEOUT }).then(($b) => { - const hasSwal = $b.find('.swal2-container').length > 0 - if (hasSwal) { - cy.get('.swal2-container', { timeout: STANDARD_TIMEOUT }).should('be.visible') - cy.contains('.swal2-container', 'Are you sure', { timeout: STANDARD_TIMEOUT }).should('exist') - - if ($b.find('.swal2-confirm').length > 0) { - cy.get('.swal2-confirm', { timeout: STANDARD_TIMEOUT }).should('be.visible').click() - } else { - cy.contains('.swal2-container button', 'Yes', { timeout: STANDARD_TIMEOUT }).click() - } - return - } - - const hasBootstrapShownModal = $b.find('.modal.show').length > 0 - if (hasBootstrapShownModal) { - cy.get('.modal.show', { timeout: STANDARD_TIMEOUT }) - .should('be.visible') - .within(() => { - cy.contains('Are you sure you want to send this email?', { timeout: STANDARD_TIMEOUT }) - .should('exist') - - if (Cypress.$('#btn-confirm-send').length > 0) { - cy.get('#btn-confirm-send', { timeout: STANDARD_TIMEOUT }) - .should('exist') - .should('be.visible') - .click() - } else { - cy.contains('button', 'Confirm', { timeout: STANDARD_TIMEOUT }).click() - } - }) - return + return true; + }); + + const now = new Date(); + const timestamp = + now.getFullYear() + + "-" + + String(now.getMonth() + 1).padStart(2, "0") + + "-" + + String(now.getDate()).padStart(2, "0") + + " " + + String(now.getHours()).padStart(2, "0") + + ":" + + String(now.getMinutes()).padStart(2, "0") + + ":" + + String(now.getSeconds()).padStart(2, "0"); + + const TEST_EMAIL_SUBJECT = `Smoke Test Email ${timestamp}`; + + function ensureLoggedInToGrantApplications() { + // Headless runs specs sequentially in the same browser process. + // Do not assume logged-out or logged-in. Detect UI state like chefsdata.cy.ts does. + cy.visit(Cypress.env("webapp.url")); + + cy.get("body", { timeout: STANDARD_TIMEOUT }).then(($body) => { + // Already authenticated + if ($body.find('button:contains("VIEW APPLICATIONS")').length > 0) { + cy.contains("VIEW APPLICATIONS", { timeout: STANDARD_TIMEOUT }).click({ + force: true, + }); + return; + } + + // Not authenticated + if ($body.find('button:contains("LOGIN")').length > 0) { + cy.contains("LOGIN", { timeout: STANDARD_TIMEOUT }) + .should("exist") + .click({ force: true }); + + cy.get("body", { timeout: STANDARD_TIMEOUT }).then(($loginBody) => { + // IDIR chooser may or may not appear + if ($loginBody.find(':contains("IDIR")').length > 0) { + cy.contains("IDIR", { timeout: STANDARD_TIMEOUT }).click({ + force: true, + }); + } + + cy.get("body", { timeout: STANDARD_TIMEOUT }).then(($authBody) => { + // Only type creds if the login form is actually present + if ($authBody.find("#user").length > 0) { + cy.get("#user", { timeout: STANDARD_TIMEOUT }).type( + Cypress.env("test1username"), + ); + cy.get("#password", { timeout: STANDARD_TIMEOUT }).type( + Cypress.env("test1password"), + ); + cy.contains("Continue", { timeout: STANDARD_TIMEOUT }).click({ + force: true, + }); + } else { + cy.log("Already authenticated"); } + }); + }); + + return; + } + + throw new Error("Unable to determine authentication state"); + }); + + cy.location("pathname", { timeout: 30000 }).should( + "include", + "/GrantApplications", + ); + } + + function switchToDefaultGrantsProgramIfAvailable() { + cy.get("body").then(($body) => { + const hasUserInitials = $body.find(".unity-user-initials").length > 0; + + if (!hasUserInitials) { + cy.log("Skipping tenant: no user initials menu found"); + return; + } + + cy.get(".unity-user-initials").click(); + + cy.get("body").then(($body2) => { + const switchLink = $body2 + .find("#user-dropdown a.dropdown-item") + .filter((_, el) => { + return (el.textContent || "").trim() === "Switch Grant Programs"; + }); + + if (switchLink.length === 0) { + cy.log( + 'Skipping tenant: "Switch Grant Programs" not present for this user/session', + ); + cy.get("body").click(0, 0); + return; + } - cy.get('#btn-confirm-send', { timeout: STANDARD_TIMEOUT }) - .should('exist') - .click({ force: true }) - }) - } + cy.wrap(switchLink.first()).click(); - it('Login', () => { - ensureLoggedInToGrantApplications() - }) + cy.url({ timeout: STANDARD_TIMEOUT }).should( + "include", + "/GrantPrograms", + ); - it('Switch to Default Grants Program if available', () => { - switchToDefaultGrantsProgramIfAvailable() - }) + cy.get("#search-grant-programs", { timeout: STANDARD_TIMEOUT }) + .should("be.visible") + .clear() + .type("Default Grants Program"); - it('Handle IDIR if required', () => { - cy.get('body').then(($body) => { - if ($body.find('#social-idir').length > 0) { - cy.get('#social-idir').should('be.visible').click() + cy.get("#UserGrantProgramsTable", { timeout: STANDARD_TIMEOUT }) + .should("be.visible") + .within(() => { + cy.contains("tbody tr", "Default Grants Program", { + timeout: STANDARD_TIMEOUT, + }) + .should("exist") + .within(() => { + cy.contains("button", "Select").should("be.enabled").click(); + }); + }); + + cy.location("pathname", { timeout: STANDARD_TIMEOUT }).should((p) => { + expect( + p.indexOf("/GrantApplications") >= 0 || p.indexOf("/auth/") >= 0, + ).to.eq(true); + }); + }); + }); + } + + function openSavedEmailFromHistoryBySubject(subject: string) { + cy.get("body", { timeout: STANDARD_TIMEOUT }).then(($body) => { + const historyTableById = $body.find("#EmailHistoryTable"); + if (historyTableById.length > 0) { + cy.get("#EmailHistoryTable", { timeout: STANDARD_TIMEOUT }) + .scrollIntoView() + .should("be.visible") + .within(() => { + cy.contains("td", subject, { timeout: STANDARD_TIMEOUT }) + .should("exist") + .click(); + }); + return; + } + + cy.contains("td", subject, { timeout: STANDARD_TIMEOUT }) + .should("exist") + .click(); + }); + } + + function confirmSendDialogIfPresent() { + cy.get("body", { timeout: STANDARD_TIMEOUT }).should(($b) => { + const hasBootstrapShownModal = $b.find(".modal.show").length > 0; + const hasSwal = $b.find(".swal2-container").length > 0; + const hasConfirmBtn = $b.find("#btn-confirm-send").length > 0; + expect(hasBootstrapShownModal || hasSwal || hasConfirmBtn).to.eq(true); + }); + + cy.get("body", { timeout: STANDARD_TIMEOUT }).then(($b) => { + const hasSwal = $b.find(".swal2-container").length > 0; + if (hasSwal) { + cy.get(".swal2-container", { timeout: STANDARD_TIMEOUT }).should( + "be.visible", + ); + cy.contains(".swal2-container", "Are you sure", { + timeout: STANDARD_TIMEOUT, + }).should("exist"); + + if ($b.find(".swal2-confirm").length > 0) { + cy.get(".swal2-confirm", { timeout: STANDARD_TIMEOUT }) + .should("be.visible") + .click(); + } else { + cy.contains(".swal2-container button", "Yes", { + timeout: STANDARD_TIMEOUT, + }).click(); + } + return; + } + + const hasBootstrapShownModal = $b.find(".modal.show").length > 0; + if (hasBootstrapShownModal) { + cy.get(".modal.show", { timeout: STANDARD_TIMEOUT }) + .should("be.visible") + .within(() => { + cy.contains("Are you sure you want to send this email?", { + timeout: STANDARD_TIMEOUT, + }).should("exist"); + + if (Cypress.$("#btn-confirm-send").length > 0) { + cy.get("#btn-confirm-send", { timeout: STANDARD_TIMEOUT }) + .should("exist") + .should("be.visible") + .click(); + } else { + cy.contains("button", "Confirm", { + timeout: STANDARD_TIMEOUT, + }).click(); } - }) - - cy.location('pathname', { timeout: 30000 }).should('include', '/GrantApplications') - }) - - it('Open an application from the list', () => { - cy.url().should('include', '/GrantApplications') - - cy.get('#GrantApplicationsTable tbody a[href^="/GrantApplications/Details?ApplicationId="]', { timeout: STANDARD_TIMEOUT }) - .should('have.length.greaterThan', 0) - - cy.get('#GrantApplicationsTable tbody a[href^="/GrantApplications/Details?ApplicationId="]') - .first() - .click() - - cy.url().should('include', '/GrantApplications/Details') - }) - - it('Open Emails tab', () => { - cy.get('#emails-tab', { timeout: STANDARD_TIMEOUT }) - .should('exist') - .should('be.visible') - .click() - - cy.contains('Emails', { timeout: STANDARD_TIMEOUT }).should('exist') - cy.contains('Email History', { timeout: STANDARD_TIMEOUT }).should('exist') - }) - - it('Open New Email form', () => { - cy.get('#btn-new-email', { timeout: STANDARD_TIMEOUT }) - .should('exist') - .should('be.visible') - .click() - - cy.contains('Email To', { timeout: STANDARD_TIMEOUT }).should('exist') - }) - - 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) - }) - - it('Set Email To address', () => { - cy.get('#EmailTo', { timeout: STANDARD_TIMEOUT }) - .should('exist') - .should('be.visible') - .clear() - .type(TEST_EMAIL_TO) - - cy.get('#EmailTo').should('have.value', TEST_EMAIL_TO) - }) - - it('Set Email CC address', () => { - cy.get('#EmailCC', { timeout: STANDARD_TIMEOUT }) - .should('exist') - .should('be.visible') - .clear() - .type(TEST_EMAIL_CC) - - cy.get('#EmailCC').should('have.value', TEST_EMAIL_CC) - }) - - it('Set Email BCC address', () => { - cy.get('#EmailBCC', { timeout: STANDARD_TIMEOUT }) - .should('exist') - .should('be.visible') - .clear() - .type(TEST_EMAIL_BCC) - - cy.get('#EmailBCC').should('have.value', TEST_EMAIL_BCC) - }) - - it('Set Email Subject', () => { - cy.get('#EmailSubject', { timeout: STANDARD_TIMEOUT }) - .should('exist') - .should('be.visible') - .clear() - .type(TEST_EMAIL_SUBJECT) - - cy.get('#EmailSubject').should('have.value', TEST_EMAIL_SUBJECT) - }) - - it('Save the email', () => { - cy.get('#btn-save', { timeout: STANDARD_TIMEOUT }) - .should('exist') - .should('be.visible') - .click() - - cy.get('#btn-new-email', { timeout: STANDARD_TIMEOUT }).should('be.visible') - }) - - it('Select saved email from Email History', () => { - openSavedEmailFromHistoryBySubject(TEST_EMAIL_SUBJECT) - - cy.get('#EmailTo', { timeout: STANDARD_TIMEOUT }).should('be.visible') - cy.get('#EmailCC').should('be.visible') - cy.get('#EmailBCC').should('be.visible') - cy.get('#EmailSubject').should('be.visible') - - cy.get('#btn-send', { timeout: STANDARD_TIMEOUT }).should('exist') - cy.get('#btn-save', { timeout: STANDARD_TIMEOUT }).should('exist') - }) - - it('Send the email', () => { - cy.get('#btn-send', { timeout: STANDARD_TIMEOUT }) - .should('exist') - .should('be.visible') - .should('not.be.disabled') - .click() - }) - - it('Confirm send email in dialog', () => { - confirmSendDialogIfPresent() - }) - - it('Verify Logout', () => { - cy.logout() - }) -}) + }); + return; + } + + cy.get("#btn-confirm-send", { timeout: STANDARD_TIMEOUT }) + .should("exist") + .click({ force: true }); + }); + } + + it("Login", () => { + ensureLoggedInToGrantApplications(); + }); + + it("Switch to Default Grants Program if available", () => { + switchToDefaultGrantsProgramIfAvailable(); + }); + + it("Handle IDIR if required", () => { + cy.get("body").then(($body) => { + if ($body.find("#social-idir").length > 0) { + cy.get("#social-idir").should("be.visible").click(); + } + }); + + cy.location("pathname", { timeout: 30000 }).should( + "include", + "/GrantApplications", + ); + }); + + it("Open an application from the list", () => { + cy.url().should("include", "/GrantApplications"); + + cy.get( + '#GrantApplicationsTable tbody a[href^="/GrantApplications/Details?ApplicationId="]', + { timeout: STANDARD_TIMEOUT }, + ).should("have.length.greaterThan", 0); + + cy.get( + '#GrantApplicationsTable tbody a[href^="/GrantApplications/Details?ApplicationId="]', + ) + .first() + .click(); + + cy.url().should("include", "/GrantApplications/Details"); + }); + + it("Open Emails tab", () => { + cy.get("#emails-tab", { timeout: STANDARD_TIMEOUT }) + .should("exist") + .should("be.visible") + .click(); + + cy.contains("Emails", { timeout: STANDARD_TIMEOUT }).should("exist"); + cy.contains("Email History", { timeout: STANDARD_TIMEOUT }).should("exist"); + }); + + it("Open New Email form", () => { + cy.get("#btn-new-email", { timeout: STANDARD_TIMEOUT }) + .should("exist") + .should("be.visible") + .click(); + + cy.contains("Email To", { timeout: STANDARD_TIMEOUT }).should("exist"); + }); + + 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); + }); + + it("Set Email To address", () => { + cy.get("#EmailTo", { timeout: STANDARD_TIMEOUT }) + .should("exist") + .should("be.visible") + .clear() + .type(TEST_EMAIL_TO); + + cy.get("#EmailTo").should("have.value", TEST_EMAIL_TO); + }); + + it("Set Email CC address", () => { + cy.get("#EmailCC", { timeout: STANDARD_TIMEOUT }) + .should("exist") + .should("be.visible") + .clear() + .type(TEST_EMAIL_CC); + + cy.get("#EmailCC").should("have.value", TEST_EMAIL_CC); + }); + + it("Set Email BCC address", () => { + cy.get("#EmailBCC", { timeout: STANDARD_TIMEOUT }) + .should("exist") + .should("be.visible") + .clear() + .type(TEST_EMAIL_BCC); + + cy.get("#EmailBCC").should("have.value", TEST_EMAIL_BCC); + }); + + it("Set Email Subject", () => { + cy.get("#EmailSubject", { timeout: STANDARD_TIMEOUT }) + .should("exist") + .should("be.visible") + .clear() + .type(TEST_EMAIL_SUBJECT); + + cy.get("#EmailSubject").should("have.value", TEST_EMAIL_SUBJECT); + }); + + it("Save the email", () => { + cy.get("#btn-save", { timeout: STANDARD_TIMEOUT }) + .should("exist") + .should("be.visible") + .click(); + + cy.get("#btn-new-email", { timeout: STANDARD_TIMEOUT }).should( + "be.visible", + ); + }); + + it("Select saved email from Email History", () => { + openSavedEmailFromHistoryBySubject(TEST_EMAIL_SUBJECT); + + cy.get("#EmailTo", { timeout: STANDARD_TIMEOUT }).should("be.visible"); + cy.get("#EmailCC").should("be.visible"); + cy.get("#EmailBCC").should("be.visible"); + cy.get("#EmailSubject").should("be.visible"); + + cy.get("#btn-send", { timeout: STANDARD_TIMEOUT }).should("exist"); + cy.get("#btn-save", { timeout: STANDARD_TIMEOUT }).should("exist"); + }); + + it("Send the email", () => { + cy.get("#btn-send", { timeout: STANDARD_TIMEOUT }) + .should("exist") + .should("be.visible") + .should("not.be.disabled") + .click(); + }); + + it("Confirm send email in dialog", () => { + confirmSendDialogIfPresent(); + }); + + it("Verify Logout", () => { + cy.logout(); + }); +}); diff --git a/applications/Unity.AutoUI/cypress/support/auth.ts b/applications/Unity.AutoUI/cypress/support/auth.ts new file mode 100644 index 000000000..764222b8f --- /dev/null +++ b/applications/Unity.AutoUI/cypress/support/auth.ts @@ -0,0 +1,206 @@ +/** + * Authentication helper for Unity webapp + * Handles multiple authentication states and provides robust login flow + */ + +interface LoginOptions { + baseUrl?: string; + useMfa?: boolean; + timeout?: number; + username?: string; + password?: string; +} + +/** + * Detects if we're on the Keycloak login provider selection page + */ +function isKeycloakPage($body: JQuery): boolean { + return ( + $body.find(".login-pf-page").length > 0 || + $body.find("#social-idir").length > 0 || + $body.find("#social-azureidir").length > 0 + ); +} + +/** + * Detects if we're already logged in + */ +function isLoggedIn($body: JQuery): boolean { + return ( + $body.find('button:contains("VIEW APPLICATIONS")').length > 0 || + $body.find("#GrantApplicationsTable").length > 0 + ); +} + +/** + * Detects if we're on the login landing page + */ +function isLoginPage($body: JQuery): boolean { + return $body.find('button:contains("LOGIN")').length > 0; +} + +/** + * Handles the Keycloak IDIR selection and login form + */ +function handleKeycloakLogin( + options: LoginOptions, + useMfa: boolean, + timeout: number, +): void { + cy.log("🔑 Handling Keycloak login flow"); + + cy.get("body", { timeout }).then(($body) => { + // Click appropriate IDIR provider + if (useMfa && $body.find("#social-azureidir").length > 0) { + cy.log("Selecting IDIR - MFA"); + cy.get("#social-azureidir", { timeout }).should("be.visible").click(); + } else if ($body.find("#social-idir").length > 0) { + cy.log("Selecting IDIR"); + cy.get("#social-idir", { timeout }).should("be.visible").click(); + } else { + throw new Error( + "Expected Keycloak IDIR provider buttons but none found. Available: " + + $body + .find("a[id^='social-']") + .map((_, el) => el.id) + .get() + .join(", "), + ); + } + }); + + // Handle username/password form if it appears + cy.get("body", { timeout }).then(($loginBody) => { + if ($loginBody.find("#user").length > 0) { + cy.log("Entering IDIR credentials"); + + const username = options.username || Cypress.env("test1username"); + const password = options.password || Cypress.env("test1password"); + + cy.get("#user", { timeout }) + .should("be.visible") + .type(username, { log: false }); + + cy.get("#password", { timeout }) + .should("be.visible") + .type(password, { log: false }); + + // Look for Continue button or submit the form + cy.get("body").then(($formBody) => { + if ($formBody.find('button:contains("Continue")').length > 0) { + cy.contains("button", "Continue", { timeout }).click(); + } else if ($formBody.find("input[type='submit']").length > 0) { + cy.get("input[type='submit']", { timeout }).click(); + } else { + cy.log("⚠️ No submit button found, attempting form submission"); + cy.get("#user").parents("form").submit(); + } + }); + } else { + cy.log("✓ Already authenticated, skipping credentials"); + } + }); +} + +/** + * Ensures we end up at the GrantApplications page + */ +function ensureGrantApplicationsPage(timeout: number): void { + cy.location("pathname", { timeout }).then((pathname) => { + if (pathname.includes("/GrantApplications")) { + cy.log("✓ Already at GrantApplications page"); + return; + } + + // Check if VIEW APPLICATIONS button exists + cy.get("body", { timeout }).then(($body) => { + if ($body.find('button:contains("VIEW APPLICATIONS")').length > 0) { + cy.log("Clicking VIEW APPLICATIONS button"); + cy.contains("button", "VIEW APPLICATIONS", { timeout }) + .should("be.visible") + .click(); + } + }); + }); + + // Final assertion - we should be at GrantApplications + cy.location("pathname", { timeout }).should("include", "/GrantApplications"); + cy.log("✓ Successfully navigated to GrantApplications"); +} + +/** + * Performs the actual login flow + */ +function performLogin(options: LoginOptions = {}): void { + const baseUrl = options.baseUrl || (Cypress.env("webapp.url") as string); + const useMfa = options.useMfa || false; + const timeout = options.timeout || 20000; + + cy.visit(baseUrl); + + cy.get("body", { timeout }).then(($body) => { + // Check if already logged in + if (isLoggedIn($body)) { + cy.log("✓ Already logged in"); + return; + } + + // Click LOGIN button if on landing page + if (isLoginPage($body)) { + cy.log("Clicking LOGIN button"); + cy.contains("button", "LOGIN", { timeout }).click(); + } + }); + + // Handle Keycloak login if needed + cy.get("body", { timeout }).then(($body) => { + if (isKeycloakPage($body)) { + handleKeycloakLogin(options, useMfa, timeout); + } + }); + + // Wait for login to complete - verify we're at GrantApplications or can navigate there + ensureGrantApplicationsPage(timeout); +} + +/** + * Robust login helper that handles multiple Unity webapp states + * + * @example + * // In your test + * import { loginIfNeeded } from "../support/auth"; + * + * beforeEach(() => { + * loginIfNeeded(); + * }); + */ +export function loginIfNeeded( + options: LoginOptions = {}, +): Cypress.Chainable { + const username = options.username || (Cypress.env("test1username") as string); + const baseUrl = options.baseUrl || (Cypress.env("webapp.url") as string); + const sessionId = `unity-${baseUrl}-${username}`; + + return cy + .session( + sessionId, + () => { + performLogin(options); + }, + { + validate() { + // Lightweight validation: check auth cookies exist without visiting + return cy.getCookie(".AspNetCore.Cookies").then((cookie) => { + if (!cookie) { + throw new Error("Session expired - auth cookie missing"); + } + }); + }, + cacheAcrossSpecs: true, + }, + ) + .then(() => { + cy.visit(baseUrl); + ensureGrantApplicationsPage(options.timeout || 20000); + }); +} diff --git a/applications/Unity.AutoUI/cypress/support/commands.ts b/applications/Unity.AutoUI/cypress/support/commands.ts index b302bf72e..686e928f5 100644 --- a/applications/Unity.AutoUI/cypress/support/commands.ts +++ b/applications/Unity.AutoUI/cypress/support/commands.ts @@ -1,5 +1,5 @@ // *********************************************** -// This commands.ts file is used to +// This commands.ts file is used to // create custom commands and overwrite // existing commands. // @@ -8,33 +8,48 @@ // https://on.cypress.io/custom-commands // *********************************************** -Cypress.Commands.add('login', () => { +Cypress.Commands.add("login", () => { // 1.) Load Main Page - cy.visit(Cypress.env('webapp.url')) + cy.visit(Cypress.env("webapp.url")); // 2.) Login to the Default Grant Tenant - cy.contains('LOGIN').should('exist').click() - cy.wait(1000) - cy.contains('IDIR').should('exist').click() - cy.wait(1000) - cy.get('body').then($body => { // Check if you're already logged in. - if ($body.find('#user').length) { // If #user exists, perform the login steps - cy.get("#user").type(Cypress.env('test1username')); - cy.get("#password").type(Cypress.env('test1password')); - cy.contains("Continue").should('exist').click(); - } else {// If #user does not exist, log a message and proceed - cy.log('Already logged in'); - } + cy.contains("LOGIN").should("exist").click(); + cy.wait(1000); + cy.contains("IDIR").should("exist").click(); + cy.wait(1000); + cy.get("body").then(($body) => { + // Check if you're already logged in. + if ($body.find("#user").length) { + // If #user exists, perform the login steps + cy.get("#user").type(Cypress.env("test1username")); + cy.get("#password").type(Cypress.env("test1password")); + cy.contains("Continue").should("exist").click(); + } else { + // If #user does not exist, log a message and proceed + cy.log("Already logged in"); + } + }); +}); + +Cypress.Commands.add("logout", () => { + cy.log("🚪 Logging out"); + + // Send logout request FIRST (while cookies are present to invalidate server session) + cy.request({ + method: "GET", + url: Cypress.env("webapp.url") + "Account/Logout", + failOnStatusCode: false, }); - }); -Cypress.Commands.add('logout', () => { - // 1.) Load Main Page - cy.visit(Cypress.env('webapp.url')) - // 2.) Logout - cy.request('GET', (Cypress.env('webapp.url') + 'Account/Logout')) - cy.wait(1000) - cy.visit(Cypress.env('webapp.url')) -}) + // THEN clear client-side storage + cy.clearCookies(); + cy.clearLocalStorage(); + + // Visit and verify logout + cy.visit(Cypress.env("webapp.url")); + cy.get('button:contains("LOGIN")', { timeout: 10000 }).should("be.visible"); + + cy.log("✓ Logged out"); +}); /// @@ -43,17 +58,23 @@ interface SubmissionDetail { [key: string]: string; // Allow additional properties with string keys } -Cypress.Commands.add('getSubmissionDetail', (key: string) => { - return cy.fixture<{submissionDetails: SubmissionDetail[]}>('submissions.json').then(({submissionDetails}) => { - const environment = Cypress.env('environment'); - const submissionDetail = submissionDetails.find(detail => detail.unityEnv === environment); - - if (submissionDetail && submissionDetail.hasOwnProperty(key)) { - return submissionDetail[key]; - } else { - throw new Error(`No submission detail found for environment: ${environment} and key: ${key}`); - } - }); +Cypress.Commands.add("getSubmissionDetail", (key: string) => { + return cy + .fixture<{ submissionDetails: SubmissionDetail[] }>("submissions.json") + .then(({ submissionDetails }) => { + const environment = Cypress.env("environment"); + const submissionDetail = submissionDetails.find( + (detail) => detail.unityEnv === environment, + ); + + if (submissionDetail && submissionDetail.hasOwnProperty(key)) { + return submissionDetail[key]; + } else { + throw new Error( + `No submission detail found for environment: ${environment} and key: ${key}`, + ); + } + }); }); interface MetabaseDetail { @@ -61,34 +82,37 @@ interface MetabaseDetail { [key: string]: string; // Allow additional properties with string keys } -Cypress.Commands.add('getMetabaseDetail', (key: string) => { - return cy.fixture<{metabaseDetails: MetabaseDetail[]}>('metabase.json').then(({metabaseDetails}) => { - const environment = Cypress.env('environment'); - const submissionDetail = metabaseDetails.find(detail => detail.unityEnv === environment); - - if (submissionDetail && submissionDetail.hasOwnProperty(key)) { - return submissionDetail[key]; - } else { - throw new Error(`No submission detail found for environment: ${environment} and key: ${key}`); - } - }); +Cypress.Commands.add("getMetabaseDetail", (key: string) => { + return cy + .fixture<{ metabaseDetails: MetabaseDetail[] }>("metabase.json") + .then(({ metabaseDetails }) => { + const environment = Cypress.env("environment"); + const submissionDetail = metabaseDetails.find( + (detail) => detail.unityEnv === environment, + ); + + if (submissionDetail && submissionDetail.hasOwnProperty(key)) { + return submissionDetail[key]; + } else { + throw new Error( + `No submission detail found for environment: ${environment} and key: ${key}`, + ); + } + }); }); -Cypress.Commands.add('metabaseLogin', () => { - cy.getMetabaseDetail('baseURL').then((baseURL) => { +Cypress.Commands.add("metabaseLogin", () => { + cy.getMetabaseDetail("baseURL").then((baseURL) => { cy.visit(baseURL); // Target the username field using its `name` attribute cy.get('input[name="username"]') - .should('exist') + .should("exist") .click() - .type('iDontHave@ValidEmail.com'); // Placeholder email address + .type("iDontHave@ValidEmail.com"); // Placeholder email address // Target the password field using its `name` attribute - cy.get('input[name="password"]') - .should('exist') - .click() - .type('pointless'); // Placeholder password + cy.get('input[name="password"]').should("exist").click().type("pointless"); // Placeholder password }); }); @@ -97,62 +121,78 @@ interface chefsDetail { [key: string]: string; // Allow additional properties with string keys } -Cypress.Commands.add('getChefsDetail', (key: string) => { - return cy.fixture<{chefsDetails: chefsDetail[]}>('chefs.json').then(({chefsDetails}) => { - const environment = Cypress.env('environment'); - const submissionDetail = chefsDetails.find(detail => detail.unityEnv === environment); - - if (submissionDetail && submissionDetail.hasOwnProperty(key)) { - return submissionDetail[key]; - } else { - throw new Error(`No submission detail found for environment: ${environment} and key: ${key}`); - } - }); +Cypress.Commands.add("getChefsDetail", (key: string) => { + return cy + .fixture<{ chefsDetails: chefsDetail[] }>("chefs.json") + .then(({ chefsDetails }) => { + const environment = Cypress.env("environment"); + const submissionDetail = chefsDetails.find( + (detail) => detail.unityEnv === environment, + ); + + if (submissionDetail && submissionDetail.hasOwnProperty(key)) { + return submissionDetail[key]; + } else { + throw new Error( + `No submission detail found for environment: ${environment} and key: ${key}`, + ); + } + }); }); -Cypress.Commands.add('chefsLogin', () => { - cy.getChefsDetail('chefsBaseURL').then(baseURL => {cy.visit(baseURL); // Visit the URL fetched from chefs.json - cy.get('#app > div > main > header > header > div > div.d-print-none') - .should('exist') +Cypress.Commands.add("chefsLogin", () => { + cy.getChefsDetail("chefsBaseURL").then((baseURL) => { + cy.visit(baseURL); // Visit the URL fetched from chefs.json + cy.get("#app > div > main > header > header > div > div.d-print-none") + .should("exist") .click(); // click the login button - cy.wait(1000) - cy.get('#app > div > main > div.v-container.v-locale--is-ltr.text-center.main > div > div:nth-child(2) > div > button') - .should('exist') + cy.wait(1000); + cy.get( + "#app > div > main > div.v-container.v-locale--is-ltr.text-center.main > div > div:nth-child(2) > div > button", + ) + .should("exist") .click(); // click the idir buttton - cy.wait(1000) - cy.get('body').then($body => { // Check if you're already logged in. - if ($body.find('#user').length) { // If #user exists, perform the login steps - cy.get("#user").type(Cypress.env('test1username')); - cy.get("#password").type(Cypress.env('test1password')); - cy.contains("Continue").should('exist').click(); - } else {// If #user does not exist, log a message and proceed - cy.log('Already logged in'); - } - }); - cy.wait(1000) + cy.wait(1000); + cy.get("body").then(($body) => { + // Check if you're already logged in. + if ($body.find("#user").length) { + // If #user exists, perform the login steps + cy.get("#user").type(Cypress.env("test1username")); + cy.get("#password").type(Cypress.env("test1password")); + cy.contains("Continue").should("exist").click(); + } else { + // If #user does not exist, log a message and proceed + cy.log("Already logged in"); + } + }); + cy.wait(1000); }); }); -Cypress.Commands.add('chefsLogout', () => { - cy.getChefsDetail('chefsBaseURL').then(baseURL => {cy.visit(baseURL);}); // Load Main Page - cy.wait(1000) - cy.contains("Logout").should('exist').click()// Logout - cy.wait(1000) - cy.contains("Login").should('exist') +Cypress.Commands.add("chefsLogout", () => { + cy.getChefsDetail("chefsBaseURL").then((baseURL) => { + cy.visit(baseURL); + }); // Load Main Page + cy.wait(1000); + cy.contains("Logout").should("exist").click(); // Logout + cy.wait(1000); + cy.contains("Login").should("exist"); }); -Cypress.Commands.add('clearSessionStorage', () => { +Cypress.Commands.add("clearSessionStorage", () => { cy.window().then((window) => { window.sessionStorage.clear(); }); }); -Cypress.Commands.add('clearBrowserCache', () => { +Cypress.Commands.add("clearBrowserCache", () => { cy.window().then((win) => { win.caches.keys().then((keyList) => { - return Promise.all(keyList.map((key) => { - return win.caches.delete(key); - })); + return Promise.all( + keyList.map((key) => { + return win.caches.delete(key); + }), + ); }); }); -}); \ No newline at end of file +}); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/CasTokenService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/CasTokenService.cs index 19afc53d9..225fb33fd 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/CasTokenService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/CasTokenService.cs @@ -1,22 +1,26 @@ -using System.Net.Http; +using System; +using System.Net.Http; using System.Threading.Tasks; -using Microsoft.Extensions.Options; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Configuration; using Unity.GrantManager.Integrations; using Unity.Modules.Shared.Integrations; using Volo.Abp; using Volo.Abp.Application.Services; using Volo.Abp.Caching; using Unity.GrantManager.Integrations.Css; - using Volo.Abp.DependencyInjection; +using Volo.Abp.TenantManagement; namespace Unity.Payments.Integrations.Cas { + [RemoteService(false)] [IntegrationService] [ExposeServices(typeof(CasTokenService), typeof(ICasTokenService))] - public class CasTokenService( + IConfiguration configuration, + ICasClientCodeLookupService casClientCodeLookupService, IEndpointManagementAppService endpointManagementAppService, - IOptions casClientOptions, + ITenantRepository tenantRepository, IHttpClientFactory httpClientFactory, IDistributedCache chesTokenCache ) : ApplicationService, ICasTokenService @@ -24,19 +28,31 @@ IDistributedCache chesTokenCache private const string OAUTH_PATH = "oauth/token"; private const string CAS_API_KEY = "CasApiKey"; - public async Task GetAuthTokenAsync() + [AllowAnonymous] + public async Task GetAuthTokenAsync(Guid tenantId) { - string caseBaseUrl = await endpointManagementAppService.GetUgmUrlByKeyNameAsync(DynamicUrlKeyNames.PAYMENT_API_BASE); - ClientOptions clientOptions = new ClientOptions + var caseBaseUrl = await endpointManagementAppService.GetUgmUrlByKeyNameAsync(DynamicUrlKeyNames.PAYMENT_API_BASE); + + var tenant = await tenantRepository.GetAsync(tenantId); + var casClientCode = tenant.ExtraProperties?["CasClientCode"]?.ToString(); + + if (string.IsNullOrEmpty(casClientCode)) + { + throw new UserFriendlyException("No CAS client code configured for the current tenant. Please contact your administrator."); + } + + var casClientId = await casClientCodeLookupService.GetClientIdByCasClientCodeAsync(casClientCode) + ?? throw new UserFriendlyException($"No CAS client configuration found for CAS client code: {casClientCode}"); + + var clientSecret = configuration.GetValue($"CAS_API_KEY_{casClientCode.ToUpper()}") ?? string.Empty; + + return await new TokenService(httpClientFactory, chesTokenCache, Logger).GetAuthTokenAsync(new ClientOptions { Url = $"{caseBaseUrl}/{OAUTH_PATH}", - ClientId = casClientOptions.Value.CasClientId, - ClientSecret = casClientOptions.Value.CasClientSecret, + ClientId = casClientId, + ClientSecret = clientSecret, ApiKey = CAS_API_KEY, - }; - - TokenService tokenService = new(httpClientFactory, chesTokenCache, Logger); - return await tokenService.GetAuthTokenAsync(clientOptions); + }); } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/ICasTokenService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/ICasTokenService.cs index b809c66bb..6fb358c83 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/ICasTokenService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/ICasTokenService.cs @@ -1,9 +1,15 @@ -using System.Threading.Tasks; +using System; +using System.Threading.Tasks; using Volo.Abp.Application.Services; +using Microsoft.AspNetCore.Authorization; +using Volo.Abp; + namespace Unity.Payments.Integrations.Cas { public interface ICasTokenService : IApplicationService { - Task GetAuthTokenAsync(); + [AllowAnonymous] + [RemoteService(false)] + Task GetAuthTokenAsync(Guid tenantId); } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/InvoiceService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/InvoiceService.cs index 9e7e70623..148749505 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/InvoiceService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/InvoiceService.cs @@ -11,13 +11,17 @@ using Unity.Payments.Domain.PaymentRequests; using Volo.Abp.DependencyInjection; using System.Net.Http; +using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Logging; using Unity.Modules.Shared.Http; using Unity.GrantManager.Integrations; using Unity.Payments.Domain.Services; +using Volo.Abp.MultiTenancy; namespace Unity.Payments.Integrations.Cas { + [RemoteService(false)] + [AllowAnonymous] [IntegrationService] [ExposeServices(typeof(InvoiceService), typeof(IInvoiceService))] public class InvoiceService( @@ -27,6 +31,7 @@ public class InvoiceService( IInvoiceManager invoiceManager) : ApplicationService, IInvoiceService { private const string CFS_APINVOICE = "cfs/apinvoice"; + protected new ICurrentTenant CurrentTenant => LazyServiceProvider.LazyGetRequiredService(); private readonly Dictionary CASPaymentGroup = new() { @@ -106,8 +111,8 @@ public class InvoiceService( public async Task CreateInvoiceAsync(Invoice casAPInvoice) { - string jsonString = JsonSerializer.Serialize(casAPInvoice); - var authToken = await iTokenService.GetAuthTokenAsync(); + string jsonString = JsonSerializer.Serialize(casAPInvoice); + var authToken = await iTokenService.GetAuthTokenAsync(CurrentTenant.Id ?? Guid.Empty); string casBaseUrl = await endpointManagementAppService.GetUgmUrlByKeyNameAsync(DynamicUrlKeyNames.PAYMENT_API_BASE); var resource = $"{casBaseUrl}/{CFS_APINVOICE}/"; var response = await resilientHttpRequest.HttpAsync(HttpMethod.Post, resource, jsonString, authToken); @@ -139,7 +144,7 @@ public async Task CreateInvoiceAsync(Invoice casAPInvoice) public async Task GetCasInvoiceAsync(string invoiceNumber, string supplierNumber, string supplierSiteCode) { - var authToken = await iTokenService.GetAuthTokenAsync(); + var authToken = await iTokenService.GetAuthTokenAsync(CurrentTenant.Id ?? Guid.Empty); var casBaseUrl = await endpointManagementAppService.GetUgmUrlByKeyNameAsync(DynamicUrlKeyNames.PAYMENT_API_BASE); var resource = $"{casBaseUrl}/{CFS_APINVOICE}/{invoiceNumber}/{supplierNumber}/{supplierSiteCode}"; var response = await resilientHttpRequest.HttpAsync(HttpMethod.Get, resource, body: null, authToken); @@ -160,7 +165,7 @@ public async Task GetCasInvoiceAsync(string invoiceNumbe public async Task GetCasPaymentAsync(string invoiceNumber, string supplierNumber, string siteNumber) { - var authToken = await iTokenService.GetAuthTokenAsync(); + var authToken = await iTokenService.GetAuthTokenAsync(CurrentTenant.Id ?? Guid.Empty); var casBaseUrl = await endpointManagementAppService.GetUgmUrlByKeyNameAsync(DynamicUrlKeyNames.PAYMENT_API_BASE); var resource = $"{casBaseUrl}/{CFS_APINVOICE}/{invoiceNumber}/{supplierNumber}/{siteNumber}"; var response = await resilientHttpRequest.HttpAsync(HttpMethod.Get, resource, body: null, authToken); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/SupplierService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/SupplierService.cs index a6dccf31e..6edecbec5 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/SupplierService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/SupplierService.cs @@ -4,6 +4,7 @@ using System.Text.Json; using Volo.Abp.Application.Services; using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; using System.Net.Http; using Unity.Modules.Shared.Http; using Volo.Abp.EventBus.Local; @@ -12,12 +13,15 @@ using Unity.Modules.Shared.Correlation; using System; using System.Collections.Generic; +using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Unity.GrantManager.Integrations; namespace Unity.Payments.Integrations.Cas { + [RemoteService(false)] + [AllowAnonymous] [IntegrationService] [ExposeServices(typeof(SupplierService), typeof(ISupplierService))] public class SupplierService : ApplicationService, ISupplierService @@ -28,6 +32,8 @@ public class SupplierService : ApplicationService, ISupplierService private readonly ILocalEventBus localEventBus; private readonly IResilientHttpRequest resilientHttpRequest; private readonly ICasTokenService iTokenService; + protected new ICurrentTenant CurrentTenant => LazyServiceProvider.LazyGetRequiredService(); + public SupplierService(ILocalEventBus localEventBus, IEndpointManagementAppService endpointManagementAppService, IResilientHttpRequest resilientHttpRequest, @@ -239,7 +245,7 @@ private async Task GetCasSupplierInformationByResourceAsync(string? res { if (!string.IsNullOrEmpty(resource)) { - var authToken = await iTokenService.GetAuthTokenAsync(); + var authToken = await iTokenService.GetAuthTokenAsync(CurrentTenant.Id ?? Guid.Empty); try { using var response = await resilientHttpRequest.HttpAsync(HttpMethod.Get, resource, body: null, authToken); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml index 751f452ef..7b0f73fdd 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml @@ -26,6 +26,26 @@ color: var(--lpx-danger); border-color: var(--lpx-danger); } + + .parent-child-group { + padding: 0; + margin-bottom: 16px; + overflow: hidden; + } + + .parent-child-group.has-error { + border: 1px solid var(--lpx-danger); + } + + .parent-child-group.has-error .single-payment { + border: none; + border-bottom: 1px solid var(--bs-border-color); + } + + .parent-child-group-error { + padding: 4px 12px 4px 12px; + background-color: #FEEAEA; + }
@@ -60,24 +80,54 @@ + @{ + string? currentGroupKey = null; + bool groupOpen = false; + } @for (var i = 0; i < Model.ApplicationPaymentRequestForm?.Count; i++) { -
+ var item = Model.ApplicationPaymentRequestForm[i]; + var itemParentReference = !string.IsNullOrEmpty(item.ParentReferenceNo) + ? item.ParentReferenceNo + : item.SubmissionConfirmationCode; + string? itemGroupKey = item.IsPartOfParentChildGroup + ? itemParentReference + : null; + + string? nextGroupKey = null; + if (i + 1 < Model.ApplicationPaymentRequestForm.Count) + { + var next = Model.ApplicationPaymentRequestForm[i + 1]; + var nextParentReference = !string.IsNullOrEmpty(next.ParentReferenceNo) + ? next.ParentReferenceNo + : next.SubmissionConfirmationCode; + nextGroupKey = next.IsPartOfParentChildGroup + ? nextParentReference + : null; + } + + if (itemGroupKey != null && itemGroupKey != currentGroupKey) + { + @:
+ groupOpen = true; + } + +
- @Model.ApplicationPaymentRequestForm[i].ApplicantName/@Model.ApplicationPaymentRequestForm[i].InvoiceNumber - @if (!string.IsNullOrEmpty(Model.ApplicationPaymentRequestForm[i].ParentReferenceNo)) + @item.ApplicantName/@item.InvoiceNumber + @if (!string.IsNullOrEmpty(item.ParentReferenceNo)) { -   (Parent Id: @Model.ApplicationPaymentRequestForm[i].ParentReferenceNo) +   (Parent Id: @item.ParentReferenceNo) }
- + data-parameter="@item.CorrelationId" />
@@ -93,42 +143,50 @@ + - + - + disabled="@item.DisableFields" + onchange='checkMaxValueRequest("@item.CorrelationId",this, @item.RemainingAmount)' /> - + - + -
+ + if (groupOpen && nextGroupKey != itemGroupKey) + { + @: + @:
+ groupOpen = false; + } + + currentGroupKey = itemGroupKey; }