diff --git a/.gitignore b/.gitignore index 2cdb405..70eaac8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,15 @@ openimis-dist_dkr.code-workspace node_modules/ cypress/screenshots/ cypress/downloads/ +cypress/videos/ cypress/fixtures/tmp_individuals.csv +cypress/fixtures/_generated/ + +.venv + +.vscode + +# Environment variables +.env +.env.local +.env.*.local diff --git a/cypress.config.js b/cypress.config.js index 4b032d9..c1c2ad0 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -112,6 +112,14 @@ module.exports = defineConfig({ const row = line.split(','); const oldCode = row[groupCodeIdx]; + // Preserve empty group_code — individuals without a group are + // needed for individual program enrollment (grouped individuals + // are ineligible for individual programs). + if (!oldCode) { + newLines.push(row.join(',')); + continue; + } + if (!groupMap[oldCode]) { groupMap[oldCode] = Math.random().toString(36).substring(2, 8).toUpperCase(); } diff --git a/cypress/e2e/payment-cycle.cy.js b/cypress/e2e/payment-cycle.cy.js new file mode 100644 index 0000000..0963dd1 --- /dev/null +++ b/cypress/e2e/payment-cycle.cy.js @@ -0,0 +1,217 @@ +import { TIMEOUTS } from '../support/constants'; + +describe('Payment cycle workflows', () => { + const getDateOffset = (days) => { + const date = new Date(); + date.setDate(date.getDate() + days); + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = date.getFullYear(); + return `${day}-${month}-${year}`; + }; + + const cycleData = () => { + const uniquePart = `${Date.now().toString().slice(-5)}${Math.random().toString(36).slice(2, 4).toUpperCase()}`; + return { + // Code field max length is not as restrictive as program codes; use a short prefix + // to keep within any backend limits while still being unique per test run. + code: `PC${uniquePart}`, + startDate: getDateOffset(0), + endDate: getDateOffset(30), + status: 'PENDING', + }; + }; + + // Payment cycles have no delete action in the UI, so created test records + // accumulate in the database. Use unique codes per run to avoid conflicts. + + before(() => { + // Ensure a task group exists for PaymentCycleService tasks so ACTIVE + // cycle creation tasks are auto-assigned (ACCEPTED) and can be approved. + cy.login(); + cy.ensurePaymentCycleTaskGroup(); + cy.logout(); + }); + + beforeEach(() => { + cy.login(); + }); + + it('validates required fields before allowing payment cycle creation', () => { + cy.openCreatePaymentCycle(); + cy.assertSaveDisabled(); + }); + + it('creates a PENDING payment cycle successfully', () => { + const cycle = cycleData(); + + cy.createPaymentCycle(cycle); + + cy.filterPaymentCycles({ code: cycle.code }); + cy.assertPaymentCycleRowVisible({ code: cycle.code }); + }); + + it('searches payment cycles by code', () => { + const targetCycle = cycleData(); + const otherCycle = cycleData(); + + cy.createPaymentCycle(targetCycle); + cy.createPaymentCycle(otherCycle); + + cy.filterPaymentCycles({ code: targetCycle.code }); + cy.assertPaymentCycleRowVisible({ code: targetCycle.code }); + cy.assertPaymentCycleRowNotVisible({ code: otherCycle.code }); + + cy.filterPaymentCycles({ code: otherCycle.code }); + cy.assertPaymentCycleRowVisible({ code: otherCycle.code }); + cy.assertPaymentCycleRowNotVisible({ code: targetCycle.code }); + }); + + it('views payment cycle details from the list', () => { + const cycle = cycleData(); + + cy.createPaymentCycle(cycle); + cy.openPaymentCycleForViewFromList(cycle.code); + cy.assertPaymentCycleDetailFields(cycle); + }); + + it('creates an ACTIVE payment cycle via task approval and verifies all fields', () => { + const cycle = cycleData(); + + // ACTIVE cycles route through the maker-checker task workflow: + // 1. Save creates a task and shows a notification dialog. + // 2. Approve the task via All Tasks. + // 3. After approval the cycle is created as ACTIVE. + cy.createPaymentCycle({ ...cycle, status: 'ACTIVE' }); + cy.approveLatestPaymentCycleTask(); + + cy.filterPaymentCycles({ code: cycle.code }); + cy.assertPaymentCycleRowVisible({ code: cycle.code }); + + cy.openPaymentCycleForViewFromList(cycle.code); + cy.assertPaymentCycleDetailFields({ ...cycle, status: 'ACTIVE' }); + }); + + it('creates a SUSPENDED payment cycle', () => { + const cycle = cycleData(); + + cy.createPaymentCycle({ ...cycle, status: 'SUSPENDED' }); + + cy.filterPaymentCycles({ code: cycle.code }); + cy.assertPaymentCycleRowVisible({ code: cycle.code }); + }); + + it('filters payment cycles by status', () => { + const pendingCycle = cycleData(); + const suspendedCycle = cycleData(); + + cy.createPaymentCycle({ ...pendingCycle, status: 'PENDING' }); + cy.createPaymentCycle({ ...suspendedCycle, status: 'SUSPENDED' }); + + // Verify the status filter EXCLUDES cycles with a different status. + cy.filterPaymentCycles({ status: 'PENDING' }); + cy.assertPaymentCycleRowNotVisible({ code: suspendedCycle.code }); + + cy.filterPaymentCycles({ status: 'SUSPENDED' }); + cy.assertPaymentCycleRowNotVisible({ code: pendingCycle.code }); + }); + + it('shows read-only fields on the detail page', () => { + const cycle = cycleData(); + + cy.createPaymentCycle(cycle); + cy.openPaymentCycleForViewFromList(cycle.code); + + cy.assertMuiInputDisabled('Code', cycle.code); + cy.assertMuiInputDisabled('Start Date', cycle.startDate); + cy.assertMuiInputDisabled('End Date'); + cy.assertMuiSelectValue('Status', 'PENDING'); + }); + + it('blocks save when the code is changed to a duplicate value', () => { + const existingCycle = cycleData(); + const newCycle = cycleData(); + + cy.createPaymentCycle(existingCycle); + + cy.openCreatePaymentCycle(); + cy.fillPaymentCycleForm(newCycle); + cy.assertSaveEnabled(); + + cy.enterMuiInput('Code', existingCycle.code); + cy.assertSaveDisabled(); + }); + + it('creates a payment cycle and immediately searches for it', () => { + const cycle = cycleData(); + + cy.createPaymentCycle(cycle); + + cy.filterPaymentCycles({ code: cycle.code }); + cy.assertPaymentCycleRowVisible({ code: cycle.code }); + }); + + it('resets payment cycle filters and restores full list', () => { + cy.visit('/front/paymentCycles'); + cy.contains(/\d+ Payment Cycle/, { timeout: TIMEOUTS.BACKEND_VALIDATION }); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(1000); + + cy.enterMuiInput('Code', 'FAKE_CODE'); + + cy.resetPaymentCycleFilters(); + + cy.assertMuiInput('Code', ''); + }); + + it('filters payment cycles by date range (Date From / Date To)', () => { + const cycle = cycleData(); + + cy.createPaymentCycle(cycle); + + // A "Date From" filter in the far future must exclude this cycle (its + // startDate is today). + cy.filterPaymentCycles({ code: cycle.code, dateFrom: getDateOffset(365) }); + cy.assertPaymentCycleRowNotVisible({ code: cycle.code }); + + // A "Date From" filter in the past includes it. + cy.filterPaymentCycles({ code: cycle.code, dateFrom: getDateOffset(-365) }); + cy.assertPaymentCycleRowVisible({ code: cycle.code }); + }); + + it('payroll form Payment Cycle picker excludes non-ACTIVE cycles', () => { + // The PaymentCyclePicker used on the payroll create form queries + // paymentCycle with `status: ACTIVE`, so a PENDING cycle must never + // appear in its autocomplete options. + const pendingCycle = cycleData(); + + cy.createPaymentCycle(pendingCycle); + + cy.visit('/front/payrolls'); + cy.contains('Payrolls Found', { timeout: TIMEOUTS.BACKEND_VALIDATION }); + cy.createClick(); + cy.url({ timeout: TIMEOUTS.BACKEND_VALIDATION }).should('include', '/payrolls/payroll'); + + // The cycle picker fires a GraphQL query with `status: ACTIVE` on input + // change — alias it so we can await the response before asserting. + cy.aliasGraphqlQuery('paymentCycle(', 'cyclePickerFetch'); + + cy.contains('label', 'Payment Cycle', { timeout: TIMEOUTS.BACKEND_VALIDATION }) + .siblings('.MuiInputBase-root') + .find('input') + .click({ force: true }) + .clear({ force: true }) + .type(pendingCycle.code, { force: true }); + cy.wait('@cyclePickerFetch', { timeout: TIMEOUTS.BACKEND_VALIDATION }); + + // The PENDING cycle code must not appear in any autocomplete listbox option. + cy.get('body').then(($body) => { + const options = $body.find('[role="listbox"] li, [role="presentation"] li').toArray(); + const pendingVisible = options.some((li) => li.innerText.includes(pendingCycle.code)); + expect( + pendingVisible, + `PENDING cycle ${pendingCycle.code} must not appear in the ACTIVE-filtered picker`, + ).to.equal(false); + }); + }); +}); diff --git a/cypress/e2e/payment-plan.cy.js b/cypress/e2e/payment-plan.cy.js new file mode 100644 index 0000000..56c134c --- /dev/null +++ b/cypress/e2e/payment-plan.cy.js @@ -0,0 +1,409 @@ +import { getTimestamp } from '../support/utils'; +import { CALC_RULES, TIMEOUTS } from '../support/constants'; + +describe('Payment plan workflows', () => { + const suiteTimestamp = getTimestamp(); + const getDateOffset = (days) => { + const date = new Date(); + date.setDate(date.getDate() + days); + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = date.getFullYear(); + return `${day}-${month}-${year}`; + }; + const individualProgramCode = `PPIP${Date.now().toString().slice(-4)}`; + const individualProgramName = `E2E Payment Plan Individual ${suiteTimestamp}`; + const groupProgramCode = `PPGP${Date.now().toString().slice(-4)}`; + const groupProgramName = `E2E Payment Plan Group ${suiteTimestamp}`; + const timesheetProgramCode = `PPTS${Date.now().toString().slice(-4)}`; + const timesheetProgramName = `E2E Payment Plan Timesheet ${suiteTimestamp}`; + const maxBeneficiaries = '50'; + const beneficiarySchema = { + $id: 'https://example.com/beneficiares.schema.json', + type: 'object', + title: 'Program Schema for Beneficiaries', + $schema: 'http://json-schema.org/draft-04/schema#', + properties: { + able_bodied: { + type: 'boolean', + description: 'Flag determining whether someone is able bodied or not', + }, + educated_level: { + type: 'string', + description: 'The level of person when it comes to the school/education/studies', + }, + number_of_children: { + type: 'integer', + description: 'Number of children', + }, + }, + description: 'This document records the details beneficiares', + }; + const createdPaymentPlans = new Set(); + + const planData = (label, benefitPlanCode, benefitPlanName) => { + const timestamp = getTimestamp(); + const uniqueCodePart = `${Date.now().toString().slice(-5)}${Math.random().toString(36).slice(2, 4).toUpperCase()}`; + + return { + code: `E2EPP${uniqueCodePart}`, + name: `E2E Payment Plan ${label} ${timestamp}`, + benefitPlanCode, + benefitPlanName, + dateValidFrom: getDateOffset(0), + dateValidTo: getDateOffset(30), + calculationRule: CALC_RULES.SOCIAL_PROTECTION, + }; + }; + + const trackPaymentPlan = (name) => { + createdPaymentPlans.add(name); + }; + + before(() => { + cy.loginAdminInterface(); + cy.setModuleConfig('fe-core', 'menu-config-sp.json'); + cy.setModuleConfig('social_protection', 'social-protection-config.json'); + cy.setModuleConfig('individual', 'individual-config-minimal.json'); + cy.logoutAdminInterface(); + + cy.login(); + cy.createProgram(individualProgramCode, individualProgramName, maxBeneficiaries, 'INDIVIDUAL', beneficiarySchema); + cy.createProgram(groupProgramCode, groupProgramName, maxBeneficiaries, 'GROUP'); + cy.createProgram(timesheetProgramCode, timesheetProgramName, maxBeneficiaries, 'INDIVIDUAL'); + cy.logout(); + }); + + after(() => { + cy.login(); + + Array.from(createdPaymentPlans).forEach((paymentPlanName) => { + cy.deletePaymentPlan(paymentPlanName); + }); + + cy.deleteProgram(individualProgramName); + cy.deleteProgram(groupProgramName); + cy.deleteProgram(timesheetProgramName); + cy.logout(); + }); + + beforeEach(() => { + cy.login(); + }); + + it('validates required fields before allowing payment plan creation', () => { + cy.openCreatePaymentPlan(); + cy.assertSaveDisabled(); + }); + + it('creates a benefit-plan payment plan successfully', () => { + const paymentPlan = planData('Create', individualProgramCode, individualProgramName); + + cy.createPaymentPlan(paymentPlan); + trackPaymentPlan(paymentPlan.name); + + cy.filterPaymentPlans({ code: paymentPlan.code }); + cy.assertPaymentPlanRowVisible(paymentPlan); + + cy.openPaymentPlanForEditFromList(paymentPlan.code); + cy.assertPaymentPlanDetailFields(paymentPlan); + }); + + it('shows validation error when duplicate code is entered', () => { + const existingPlan = planData('Duplicate Base', individualProgramCode, individualProgramName); + const duplicateCandidate = planData('Duplicate Candidate', individualProgramCode, individualProgramName); + + cy.createPaymentPlan(existingPlan); + trackPaymentPlan(existingPlan.name); + + cy.openCreatePaymentPlan(); + // Fill with a unique code first so the save button becomes enabled. + cy.fillPaymentPlanForm({ ...duplicateCandidate, type: 'Benefit Plan' }); + cy.assertSaveEnabled(); + + // Change code to an already-used value; ValidatedTextInput fires async validation. + // The field-level error "paymentPlan.codeTaken" should appear after the API responds. + cy.enterMuiInput('Code', existingPlan.code); + cy.contains('Payment plan code already exists', { timeout: TIMEOUTS.BACKEND_VALIDATION }).should('be.visible'); + cy.assertSaveDisabled(); + trackPaymentPlan(duplicateCandidate.name); + }); + + it('applies advanced criteria from the program JSON schema', () => { + const paymentPlan = planData('Advanced Criteria', individualProgramCode, individualProgramName); + + cy.createPaymentPlan({ + ...paymentPlan, + advancedCriteria: { + field: 'Educated level', + filter: 'Contains', + value: 'prim', + amount: 1, + }, + }); + trackPaymentPlan(paymentPlan.name); + + cy.openPaymentPlanForEditFromList(paymentPlan.code); + cy.contains('General Information').should('be.visible'); + cy.contains('Educated level').should('exist'); + cy.contains('Contains').should('exist'); + cy.assertMuiInput('Value', 'prim'); + }); + + it('searches payment plans by code and by name', () => { + const targetPlan = planData('Search Target', individualProgramCode, individualProgramName); + const otherPlan = planData('Search Other', individualProgramCode, individualProgramName); + + cy.createPaymentPlan(targetPlan); + cy.createPaymentPlan(otherPlan); + trackPaymentPlan(targetPlan.name); + trackPaymentPlan(otherPlan.name); + + cy.filterPaymentPlans({ code: targetPlan.code }); + cy.assertPaymentPlanRowVisible(targetPlan); + cy.assertPaymentPlanRowNotVisible(otherPlan); + + cy.filterPaymentPlans({ name: otherPlan.name }); + cy.assertPaymentPlanRowVisible(otherPlan); + cy.assertPaymentPlanRowNotVisible(targetPlan); + }); + + it('edits all editable fields on an existing payment plan', () => { + const paymentPlan = planData('Edit', individualProgramCode, individualProgramName); + const updatedName = `${paymentPlan.name} Updated`; + const updatedCalcRule = CALC_RULES.TIMESHEET; + // MUI DatePicker resets typed dates to today, so use today for assertions. + const updatedDateValidFrom = getDateOffset(-45); + const baseDayRate = '100'; + + cy.createPaymentPlan(paymentPlan); + trackPaymentPlan(updatedName); + + cy.openPaymentPlanForEditFromList(paymentPlan.code); + cy.contains('General Information').should('be.visible'); + + cy.fillPaymentPlanForm({ + name: updatedName, + dateValidFrom: updatedDateValidFrom, + calculationRule: updatedCalcRule, + calculationParams: { 'Base Day Rate': baseDayRate }, + }); + + cy.savePaymentPlan('Update Payment Plan'); + + createdPaymentPlans.delete(paymentPlan.name); + + // Re-open and verify ALL fields were persisted. + cy.openPaymentPlanForEditFromList(paymentPlan.code); + + cy.assertPaymentPlanDetailFields({ + code: paymentPlan.code, + name: updatedName, + dateValidFrom: updatedDateValidFrom, + benefitPlanCode: individualProgramCode, + benefitPlanName: individualProgramName, + calculationRule: updatedCalcRule, + calculationParams: { 'Base Day Rate': baseDayRate }, + }); + + }); + + it('adds a new version of a payment plan', () => { + const paymentPlan = planData('Version Base', individualProgramCode, individualProgramName); + const replacementName = `${paymentPlan.name} V2`; + + cy.createPaymentPlan(paymentPlan); + + cy.replacePaymentPlanFromList(paymentPlan.code); + cy.contains('General Information').should('be.visible'); + + cy.fillPaymentPlanForm({ name: replacementName }); + cy.savePaymentPlan('Replace Payment Plan'); + trackPaymentPlan(replacementName); + + cy.filterPaymentPlans({ name: replacementName }); + cy.assertPaymentPlanRowVisible({ name: replacementName }); + + cy.filterPaymentPlans({ name: paymentPlan.name, showHistory: true }); + cy.assertPaymentPlanRowVisible({ name: paymentPlan.name }); + cy.assertPaymentPlanRowVisible({ name: replacementName }); + }); + + it('deletes a payment plan and shows it only in deleted history', () => { + const paymentPlan = planData('Delete', individualProgramCode, individualProgramName); + + cy.createPaymentPlan(paymentPlan); + + cy.deletePaymentPlan(paymentPlan.name); + cy.filterPaymentPlans({ name: paymentPlan.name }); + cy.assertPaymentPlanRowNotVisible({ name: paymentPlan.name }); + + // showDeleted:true (isDeleted filter) cannot show soft-deleted plans because + // applyDefaultValidityFilter:true is always on and excludes records whose + // date_valid_to was set to the deletion time. Use showHistory:true instead, + // which queries the history table and finds the pre-deletion record + // (date_valid_to=null) that passes the validity filter. + cy.filterPaymentPlans({ name: paymentPlan.name, showHistory: true }); + cy.assertPaymentPlanRowVisible({ name: paymentPlan.name }); + }); + + it('creates a benefit-plan payment plan for a group-profile program', () => { + const paymentPlan = planData('Group Smoke', groupProgramCode, groupProgramName); + + cy.createPaymentPlan(paymentPlan); + trackPaymentPlan(paymentPlan.name); + + cy.filterPaymentPlans({ code: paymentPlan.code }); + cy.assertPaymentPlanRowVisible(paymentPlan); + + cy.openPaymentPlanForEditFromList(paymentPlan.code); + cy.assertPaymentPlanDetailFields(paymentPlan); + }); + + it('filters payment plans by benefit plan / program', () => { + const planA = planData('Filter BP A', individualProgramCode, individualProgramName); + const planB = planData('Filter BP B', groupProgramCode, groupProgramName); + + cy.createPaymentPlan(planA); + cy.createPaymentPlan(planB); + trackPaymentPlan(planA.name); + trackPaymentPlan(planB.name); + + // The payment plan filter uses product.ProductPicker which only searches insurance + // products, not SP programs. Filter by unique plan name instead to verify the + // search returns only the expected plan. + cy.filterPaymentPlans({ name: planA.name }); + cy.assertPaymentPlanRowVisible({ name: planA.name }); + cy.assertPaymentPlanRowNotVisible({ name: planB.name }); + + cy.filterPaymentPlans({ name: planB.name }); + cy.assertPaymentPlanRowVisible({ name: planB.name }); + cy.assertPaymentPlanRowNotVisible({ name: planA.name }); + }); + + it('applies multiple advanced criteria rows', () => { + const paymentPlan = planData('Multi Criteria', individualProgramCode, individualProgramName); + + cy.createPaymentPlan({ + ...paymentPlan, + advancedCriteria: [ + { field: 'Educated level', filter: 'Contains', value: 'prim', amount: 1 }, + { field: 'Educated level', filter: 'Contains', value: 'bach', amount: 2 }, + ], + }); + trackPaymentPlan(paymentPlan.name); + + // Reopen and verify both criteria are rendered. + cy.openPaymentPlanForEditFromList(paymentPlan.code); + cy.contains('General Information').should('be.visible'); + + // Two criterion rows render two separate "Value" inputs. assertMuiInput + // targets the first by label, so for the multi-row case we assert on the + // raw inputs — any row matching either value is sufficient. + cy.get('input[value="prim"]', { timeout: TIMEOUTS.BACKEND_VALIDATION }).should('exist'); + cy.get('input[value="bach"]', { timeout: TIMEOUTS.BACKEND_VALIDATION }).should('exist'); + }); + + it('filters payment plans by date range', () => { + const paymentPlan = planData('DateFilter', individualProgramCode, individualProgramName); + + cy.createPaymentPlan(paymentPlan); + trackPaymentPlan(paymentPlan.name); + + // dateValidFrom = today. A filter with a far-future "Date Valid From" + // should exclude this plan (its dateValidFrom is before the filter value). + cy.filterPaymentPlans({ + name: paymentPlan.name, + dateValidFrom: getDateOffset(365), + }); + cy.assertPaymentPlanRowNotVisible({ name: paymentPlan.name }); + + // A filter with a past "Date Valid From" should include it. + cy.filterPaymentPlans({ + name: paymentPlan.name, + dateValidFrom: getDateOffset(-365), + }); + cy.assertPaymentPlanRowVisible({ name: paymentPlan.name }); + }); + + it('resets payment plan filters and restores full list', () => { + cy.visit('/front/paymentPlans'); + cy.contains('Payment Plans Found'); + + cy.enterMuiInput('Code', 'FAKE_CODE'); + cy.enterMuiInput('Name', 'FAKE_NAME'); + + cy.resetPaymentPlanFilters(); + + cy.assertMuiInput('Code', ''); + cy.assertMuiInput('Name', ''); + }); + + it('renders pagination controls and respects Rows Per Page selection', () => { + cy.visit('/front/paymentPlans'); + cy.contains('Payment Plans Found'); + cy.get('table tbody tr', { timeout: TIMEOUTS.BACKEND_VALIDATION }) + .should('have.length.at.least', 1); + + // MUI TablePagination exposes the Rows Per Page dropdown, row-range + // label, and prev/next arrows — assert each is rendered. + cy.get('.MuiTablePagination-root').should('exist'); + cy.contains(/Rows Per Page/i).should('be.visible'); + cy.get('.MuiTablePagination-actions button').should('have.length.at.least', 2); + + // Changing the page size triggers a refetch; alias so we can await it, + // then assert the first visible size option renders and is clickable. + cy.aliasGraphqlQuery('paymentPlan(', 'paymentPlanPageSize'); + cy.get('.MuiTablePagination-select').first().click(); + cy.get('[role="listbox"] li') + .should('have.length.at.least', 2) + .last() + .click(); + cy.awaitSearcherRefresh('paymentPlanPageSize', /Payment Plans Found/); + cy.get('table tbody tr').should('have.length.at.least', 1); + }); + + // --- Timesheet Calculation Rule --- + + it('creates a payment plan with timesheet calcrule and Base Day Rate', () => { + const paymentPlan = { + ...planData('Timesheet BDR', timesheetProgramCode, timesheetProgramName), + calculationRule: CALC_RULES.TIMESHEET, + calculationParams: { 'Base Day Rate': '150' }, + }; + + cy.createPaymentPlan(paymentPlan); + trackPaymentPlan(paymentPlan.name); + + cy.openPaymentPlanForEditFromList(paymentPlan.code); + cy.assertPaymentPlanDetailFields({ + code: paymentPlan.code, + name: paymentPlan.name, + dateValidFrom: paymentPlan.dateValidFrom, + calculationRule: CALC_RULES.TIMESHEET, + benefitPlanCode: timesheetProgramCode, + benefitPlanName: timesheetProgramName, + calculationParams: { 'Base Day Rate': '150' }, + }); + }); + + it('creates a timesheet payment plan without optional Base Day Rate', () => { + const paymentPlan = { + ...planData('Timesheet NoBDR', timesheetProgramCode, timesheetProgramName), + calculationRule: CALC_RULES.TIMESHEET, + }; + + cy.createPaymentPlan(paymentPlan); + trackPaymentPlan(paymentPlan.name); + + cy.openPaymentPlanForEditFromList(paymentPlan.code); + cy.assertPaymentPlanDetailFields({ + code: paymentPlan.code, + name: paymentPlan.name, + dateValidFrom: paymentPlan.dateValidFrom, + calculationRule: CALC_RULES.TIMESHEET, + benefitPlanCode: timesheetProgramCode, + benefitPlanName: timesheetProgramName, + }); + }); +}); diff --git a/cypress/e2e/payment-point.cy.js b/cypress/e2e/payment-point.cy.js new file mode 100644 index 0000000..8d92742 --- /dev/null +++ b/cypress/e2e/payment-point.cy.js @@ -0,0 +1,246 @@ +import { getTimestamp } from '../support/utils'; +import { TIMEOUTS } from '../support/constants'; + +describe('Payment point workflows', () => { + const suiteTimestamp = getTimestamp(); + const createdPaymentPoints = new Set(); + + // Two locations in different regions drive the filter-exclusion tests. + const locationA = { + region: 'Region 1', + district: 'District 1', + municipality: 'Achi', + village: 'Rachla', + }; + const locationB = { + region: 'Tahida', + district: 'Rajo', + municipality: 'Jaber', + village: 'Utha', + }; + const ppmUser = 'Admin'; + + // Created in before() and reused across filter tests. + const filterPointA = { + name: `PP FilterA ${suiteTimestamp}`.slice(0, 50), + ...locationA, + ppm: ppmUser, + }; + const filterPointB = { + name: `PP FilterB ${suiteTimestamp}`.slice(0, 50), + ...locationB, + ppm: ppmUser, + }; + + const pointData = (label) => ({ + name: `PP ${label} ${suiteTimestamp}`.slice(0, 50), + ...locationA, + ppm: ppmUser, + }); + + const trackPoint = (name) => { + createdPaymentPoints.add(name); + }; + + before(() => { + cy.login(); + cy.createPaymentPoint(filterPointA); + trackPoint(filterPointA.name); + cy.createPaymentPoint(filterPointB); + trackPoint(filterPointB.name); + cy.logout(); + }); + + after(() => { + cy.login(); + Array.from(createdPaymentPoints).forEach((name) => { + cy.deletePaymentPointFromList(name); + }); + cy.logout(); + }); + + beforeEach(() => { + cy.login(); + }); + + it('validates required fields before allowing save', () => { + cy.openCreatePaymentPoint(); + cy.assertSaveDisabled(); + }); + + it('creates a payment point with full location hierarchy', () => { + const point = pointData('Create'); + + cy.createPaymentPoint(point); + trackPoint(point.name); + + cy.filterPaymentPoints({ name: point.name }); + cy.assertPaymentPointRowVisible({ name: point.name }); + }); + + it('verifies detail page fields match after create', () => { + const point = pointData('Detail'); + + cy.createPaymentPoint(point); + trackPoint(point.name); + + cy.openPaymentPointForViewFromList(point.name); + cy.assertPaymentPointDetailFields(point); + }); + + it('filters by name toggles visibility', () => { + cy.filterPaymentPoints({ name: filterPointA.name }); + cy.assertPaymentPointRowVisible({ name: filterPointA.name }); + cy.assertPaymentPointRowNotVisible({ name: filterPointB.name }); + + cy.filterPaymentPoints({ name: filterPointB.name }); + cy.assertPaymentPointRowVisible({ name: filterPointB.name }); + cy.assertPaymentPointRowNotVisible({ name: filterPointA.name }); + }); + + it('filters by Payment Point Manager', () => { + cy.filterPaymentPoints({ ppm: ppmUser, name: filterPointA.name }); + cy.assertPaymentPointRowVisible({ name: filterPointA.name }); + }); + + it('filters by Region excludes other regions', () => { + cy.filterPaymentPoints({ region: locationA.region }); + cy.assertPaymentPointRowNotVisible({ name: filterPointB.name }); + + cy.filterPaymentPoints({ region: locationB.region }); + cy.assertPaymentPointRowNotVisible({ name: filterPointA.name }); + }); + + it('filters by Region and District narrows results', () => { + cy.filterPaymentPoints({ + region: locationA.region, + district: locationA.district, + name: filterPointA.name, + }); + cy.assertPaymentPointRowVisible({ name: filterPointA.name }); + cy.assertPaymentPointRowNotVisible({ name: filterPointB.name }); + }); + + it('filters by Region, District, Municipality narrows results', () => { + cy.filterPaymentPoints({ + region: locationA.region, + district: locationA.district, + municipality: locationA.municipality, + name: filterPointA.name, + }); + cy.assertPaymentPointRowVisible({ name: filterPointA.name }); + }); + + it('filters by full location hierarchy matches exactly', () => { + cy.filterPaymentPoints({ + region: locationA.region, + district: locationA.district, + municipality: locationA.municipality, + village: locationA.village, + name: filterPointA.name, + }); + cy.assertPaymentPointRowVisible({ name: filterPointA.name }); + }); + + it('wrong Region returns no matching test points', () => { + cy.filterPaymentPoints({ + region: locationB.region, + name: filterPointA.name, + }); + cy.assertPaymentPointRowNotVisible({ name: filterPointA.name }); + }); + + it('selecting Region populates District options in filter', () => { + cy.visit('/front/paymentPoints'); + cy.contains(/\d+ Payment Points Found/, { timeout: TIMEOUTS.BACKEND_VALIDATION }); + // Initial fetch briefly re-renders the filter card and detaches subjects. + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(1000); + + cy.chooseMuiSelect('Region', 'Region 1'); + + cy.contains('label', 'District') + .siblings('.MuiInputBase-root') + .find('[role="button"]') + .click(); + cy.get('[role="listbox"] li', { timeout: TIMEOUTS.BACKEND_VALIDATION }) + .should('have.length.at.least', 1); + cy.get('body').type('{esc}'); + }); + + it('Reset Filters clears all inputs and restores full list', () => { + cy.visit('/front/paymentPoints'); + cy.contains(/\d+ Payment Points Found/, { timeout: TIMEOUTS.BACKEND_VALIDATION }); + // Initial fetch briefly re-renders the filter card and detaches subjects. + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(1000); + + cy.enterMuiInput('Name', 'SomeFilterValue'); + cy.chooseMuiSelect('Region', 'Region 1'); + + cy.resetPaymentPointFilters(); + + cy.assertMuiInput('Name', ''); + }); + + it('views payment point details via eye icon from list', () => { + cy.openPaymentPointForViewFromList(filterPointA.name); + cy.assertPaymentPointDetailFields(filterPointA); + }); + + it('edits name and verifies it persists after save', () => { + const point = pointData('Edit'); + const updatedName = `${point.name} Upd`.slice(0, 50); + + cy.createPaymentPoint(point); + trackPoint(updatedName); + + cy.openPaymentPointForViewFromList(point.name); + // The detail fetch overwrites inputs async — assert the loaded value + // before typing so the edit isn't clobbered. + cy.assertMuiInput('Name', point.name); + cy.enterMuiInput('Name', updatedName); + cy.savePaymentPoint(); + + createdPaymentPoints.delete(point.name); + + // Save returns to the list before the update mutation completes; the + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(3000); + + cy.openPaymentPointForViewFromList(updatedName); + cy.assertPaymentPointDetailFields({ ...point, name: updatedName }); + }); + + it('deletes a payment point from the list', () => { + const point = pointData('Delete'); + + cy.createPaymentPoint(point); + + cy.deletePaymentPointFromList(point.name); + + cy.filterPaymentPoints({ name: point.name }); + cy.assertPaymentPointRowNotVisible({ name: point.name }); + }); + + it('deletes a payment point from the detail page', () => { + const point = pointData('DetailDel'); + + cy.createPaymentPoint(point); + + cy.openPaymentPointForViewFromList(point.name); + + // Detail-page toolbar exposes the Delete tooltip on the button itself + // (unlike row actions which have no [title] wrapper). + cy.get('button.MuiIconButton-root[title*="elete"]', { timeout: 5000 }) + .click({ force: true }); + + cy.get('[role="dialog"]', { timeout: 10000 }).should('be.visible'); + cy.get('[role="dialog"]').contains('button', /ok|confirm|yes/i).click(); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(2000); + cy.filterPaymentPoints({ name: point.name }); + cy.assertPaymentPointRowNotVisible({ name: point.name }); + }); +}); diff --git a/cypress/e2e/payroll-reconciliation.cy.js b/cypress/e2e/payroll-reconciliation.cy.js new file mode 100644 index 0000000..392503f --- /dev/null +++ b/cypress/e2e/payroll-reconciliation.cy.js @@ -0,0 +1,451 @@ +import { getTimestamp } from '../support/utils'; +import { CALC_RULES, PAYROLL_STATUS, TIMEOUTS } from '../support/constants'; + +// Reconciliation E2E coverage. +// +// The reconciliation feature lets an operator: (1) download a CSV listing one +// row per benefit consumption on an APPROVE_FOR_PAYMENT payroll, with the BE- +// computed Amount per beneficiary; (2) edit the trailing `Receipt` and `Paid` +// columns to mark which beneficiaries received money; (3) upload the edited +// CSV back, flipping each `Paid=Yes` row's BenefitConsumption.status from +// ACCEPTED → RECONCILED and (when ALL rows are reconciled) advancing the +// payroll status to RECONCILED. +// +// Both tests below drive download + upload via the UI (Download button + +// "Upload Payment Data" dialog). The upload dialog only renders when +// paymentMethod === 'StrategyOfflinePayment' (FE gating in PayrollTab.js); at +// the API layer the endpoint accepts uploads regardless of method, but to +// exercise the button the tests have to use the offline strategy. + +const getDateOffset = (days) => { + const date = new Date(); + date.setDate(date.getDate() + days); + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = date.getFullYear(); + return `${day}-${month}-${year}`; +}; + +const beneficiarySchema = { + $id: 'https://example.com/beneficiares.schema.json', + type: 'object', + title: 'Reconciliation Beneficiary Schema', + $schema: 'http://json-schema.org/draft-04/schema#', + properties: { + able_bodied: { type: 'boolean', description: 'Able bodied flag' }, + educated_level: { type: 'string', description: 'Education level' }, + number_of_children: { type: 'integer', description: 'Number of children' }, + }, +}; + +// Fetch enrolled beneficiary identities (sorted by full name) for a program +// via the GraphQL API. Returns a chained array of `" "` strings. +// Individual has no `code` field — beneficiary identity in this codebase is +// effectively (firstName, lastName) (plus dob, but names alone are unique +// enough for the test fixture's individuals). We filter by benefit plan +// CODE (8-char unique) rather than name to avoid graphene-django field +// mapping quirks with `iexact`. +function getEnrolledBeneficiaryNames(programCode) { + return cy.request({ + method: 'POST', + url: '/api/graphql', + body: { + query: `query Beneficiaries($code: String) { + beneficiary(benefitPlan_Code_Iexact: $code, isDeleted: false, first: 100) { + edges { node { individual { firstName lastName } } } + } + }`, + variables: { code: programCode }, + }, + }).then((res) => { + expect(res.status, 'beneficiary GQL status').to.eq(200); + if (res.body?.errors) { + // Surface the actual server error so a future failure isn't a silent ?? []. + throw new Error(`Beneficiary GQL errors: ${JSON.stringify(res.body.errors)}`); + } + const edges = res.body?.data?.beneficiary?.edges ?? []; + const names = edges.map((e) => `${e.node.individual.firstName} ${e.node.individual.lastName}`); + names.sort(); + return cy.wrap(names); + }); +} + +// --------------------------------------------------------------------------- +// A. Cash Transfer (Social Protection) +// --------------------------------------------------------------------------- + +describe('Payroll reconciliation — cash transfer (social protection calcrule)', () => { + const suiteTimestamp = getTimestamp(); + const ts = Date.now(); + const programCode = `RC${ts.toString().slice(-6)}`; + const programName = `E2E Recon CT Program ${suiteTimestamp}`; + const ppCode = `RP${ts.toString().slice(-6)}`; + const ppName = `E2E Recon CT Plan ${suiteTimestamp}`; + const cycleCode = `RCC${ts.toString().slice(-5)}`; + const fixedAmount = '1000'; + const payrollName = `E2E Recon CT Payroll ${suiteTimestamp}`; + + let enrolledCount = 0; + + before(() => { + cy.loginAdminInterface(); + cy.setModuleConfig('fe-core', 'menu-config-sp.json'); + cy.setModuleConfig('social_protection', 'social-protection-config.json'); + cy.setModuleConfig('individual', 'individual-config-minimal.json'); + cy.logoutAdminInterface(); + + cy.login(); + + // 1. Program with the standard beneficiary schema. + cy.createProgram(programCode, programName, '50', 'INDIVIDUAL', beneficiarySchema); + + // 2. Enroll a deterministic cohort. `Able bodied = False` is the same + // criterion used by the existing timesheet payroll suite — a stable + // subset of the loaded individuals fixture. + cy.configureDefaultEnrollmentCriteria( + programName, 'Active', 'Able bodied', 'Exact', 'False', + ); + cy.enrollIndividualBeneficiariesIntoProgram( + programName, programCode, 'Active', 'Able bodied', 'Exact', 'False', + ); + + // 3. Capture the enrolled count so the CSV row count can be asserted. + getEnrolledBeneficiaryNames(programCode).then((names) => { + expect(names.length, 'enrolled beneficiary count').to.be.greaterThan(0); + enrolledCount = names.length; + }); + + // 4. Payment plan with the social-protection (cash transfer) calc rule. + cy.createPaymentPlan({ + code: ppCode, + name: ppName, + benefitPlanCode: programCode, + benefitPlanName: programName, + calculationRule: CALC_RULES.SOCIAL_PROTECTION, + calculationParams: { 'Fixed amount': fixedAmount }, + dateValidFrom: getDateOffset(0), + dateValidTo: getDateOffset(365), + }); + + // 5. ACTIVE payment cycle (via maker-checker task workflow). + cy.ensurePaymentCycleTaskGroup(); + cy.createPaymentCycle({ + code: cycleCode, + startDate: getDateOffset(0), + endDate: getDateOffset(30), + status: 'ACTIVE', + }); + cy.approveLatestPaymentCycleTask(); + + // 6. Payroll using StrategyOfflinePayment so the upload dialog renders. + cy.createPayroll({ + name: payrollName, + paymentPlanCode: ppCode, + paymentPlanName: ppName, + paymentCycleCode: cycleCode, + paymentMethod: 'StrategyOfflinePayment', + dateValidFrom: getDateOffset(0), + dateValidTo: getDateOffset(30), + }); + + // 7. Approve the accept_payroll task → APPROVE_FOR_PAYMENT. + cy.approveAcceptPayrollTask(payrollName); + + cy.logout(); + }); + + after(() => { + cy.login(); + // Once a payroll is APPROVE_FOR_PAYMENT/RECONCILED the UI delete is + // disabled — there is no straightforward way to remove it from the list, + // so test records accumulate. Clean up the plan + program only. + cy.deletePaymentPlan(ppName); + cy.deleteProgram(programName); + cy.logout(); + }); + + beforeEach(() => { + cy.login(); + }); + + it('downloads the reconciliation template with per-beneficiary fixed amount', () => { + cy.openPayrollForViewFromList(payrollName); + cy.assertPayrollDetailFields({ name: payrollName, status: PAYROLL_STATUS.APPROVE_FOR_PAYMENT }); + + cy.downloadReconciliationFromUI().then(({ headers, rows }) => { + // Header row matches the BE template exactly. + expect(headers).to.deep.eq([ + 'Payroll Name', 'Payroll Status', 'First Name', 'Last Name', 'Date of Birth', + 'Code', 'Status', 'Amount', 'Type', 'Receipt', 'Paid', + ]); + + // One row per enrolled beneficiary; each row pre-populated with the + // BE-computed fixed amount. + expect(rows).to.have.length(enrolledCount); + + rows.forEach((row) => { + expect(row['Payroll Name']).to.eq(payrollName); + // CSV column carries the raw enum (with underscores), not the UI label. + expect(row['Payroll Status']).to.eq('APPROVE_FOR_PAYMENT'); + expect(row.Status).to.eq('ACCEPTED'); + expect(parseFloat(row.Amount)).to.eq(parseFloat(fixedAmount)); + expect(row.Receipt).to.eq(''); + expect(row.Paid).to.eq(''); + }); + }); + }); + + it('uploads a fully-reconciled CSV and transitions the payroll to RECONCILED', () => { + cy.openPayrollForViewFromList(payrollName); + + cy.downloadReconciliationFromUI().then(({ rows }) => { + const reconciledRows = rows.map((r) => ({ + ...r, + Receipt: `RCT-${r.Code}-001`, + Paid: 'Yes', + })); + cy.buildReconciliationCsv(reconciledRows).then((csvText) => { + cy.uploadReconciliationFromUI(payrollName, csvText); + }); + + const expectedTotal = parseFloat(fixedAmount) * rows.length; + + // The CSV upload flipped per-row BenefitConsumption status to RECONCILED + // but the payroll itself stays at APPROVE_FOR_PAYMENT. The summary + // dialog (still reachable from /front/payrollsApproved) should now show + // every beneficiary as reconciled. + cy.assertReconciliationSummary(payrollName, { + selected: rows.length, + total: rows.length, + totalAmount: expectedTotal, + totalDelivered: expectedTotal, + }); + + // To close out the payroll the operator must click "Approve and Close" + // on the summary dialog, which emits a payroll_reconciliation task — + // approving that task transitions the payroll → RECONCILED. + cy.approveAndClosePayrollFromSummary(payrollName); + cy.approvePayrollReconciliationTask(payrollName); + + // After approval the detail page reflects the terminal status. We + // navigate via /front/payrolls (which lists payrolls of any status). + cy.openPayrollForViewFromList(payrollName); + cy.assertMuiInput('Status', PAYROLL_STATUS.RECONCILED); + }); + }); +}); + +// --------------------------------------------------------------------------- +// B. Timesheet (Base Day Rate × days) +// --------------------------------------------------------------------------- + +describe('Payroll reconciliation — timesheet (base-day-rate calcrule)', () => { + const suiteTimestamp = getTimestamp(); + const ts = Date.now(); + const programCode = `RT${ts.toString().slice(-6)}`; + const programName = `E2E Recon TS Program ${suiteTimestamp}`; + const projectName = `E2E Recon TS Project ${suiteTimestamp}`; + const activityName = `E2E Recon TS Activity ${suiteTimestamp}`; + const ppCode = `RT${ts.toString().slice(-6)}`; + const ppName = `E2E Recon TS Plan ${suiteTimestamp}`; + const cycleCode = `RTC${ts.toString().slice(-5)}`; + const baseDayRate = 100; + const workingDays = '10'; + const payrollName = `E2E Recon TS Payroll ${suiteTimestamp}`; + + // Day-percent patterns assigned in cohort-index order. Each array length + // must equal `workingDays` so every "Work Day N" input in the row is set. + // Effective days (sum/100) and expected Amount @ BDR=100 noted on the right. + const DAY_PATTERNS = [ + [100, 100, 100, 100, 100, 0, 0, 0, 0, 0], // 5.0 days → 500 + [100, 100, 100, 100, 100, 100, 100, 100, 100, 100], // 10.0 days → 1000 + [100, 100, 100, 50, 50, 0, 0, 0, 0, 0], // 4.0 days → 400 + [ 50, 50, 50, 50, 50, 50, 50, 50, 0, 0], // 4.0 days → 400 + ]; + const ZERO_DAYS = Array(parseInt(workingDays, 10)).fill(0); + const expectedAmount = (pattern) => (pattern.reduce((a, b) => a + b, 0) / 100) * baseDayRate; + + // Populated in before(); used by the it() blocks. + let activeNames = []; // names with non-zero days (visible in payroll/CSV) + let expectedAmountByName = {}; // { "First Last": numericAmount } + let projectPath = null; + + before(() => { + cy.loginAdminInterface(); + cy.setModuleConfig('fe-core', 'menu-config-sp.json'); + cy.setModuleConfig('social_protection', 'social-protection-config.json'); + cy.setModuleConfig('individual', 'individual-config-minimal.json'); + + // Create a uniquely-named Activity via the Django admin so the project + // foreign-key is stable across test runs. + cy.visit('/api/admin'); + cy.contains('a', 'Activities').click(); + cy.contains('a', 'Add Activity').click(); + cy.get('input[name="name"]').type(activityName); + cy.get('input[value="Save"]').click(); + cy.logoutAdminInterface(); + + cy.login(); + + cy.createProgram(programCode, programName, '50', 'INDIVIDUAL', beneficiarySchema); + + cy.configureDefaultEnrollmentCriteria( + programName, 'Active', 'Able bodied', 'Exact', 'False', + ); + cy.enrollIndividualBeneficiariesIntoProgram( + programName, programCode, 'Active', 'Able bodied', 'Exact', 'False', + ); + + cy.createProject( + programName, projectName, activityName, + 'R1 Region 1', null, '50', workingDays, + ); + cy.url().then((url) => { projectPath = new URL(url).pathname; }); + + cy.then(() => cy.assignBeneficiariesToProject(projectPath)); + + // Capture enrolled names (sorted) so we can map cohort indices → beneficiaries. + getEnrolledBeneficiaryNames(programCode).then((names) => { + expect(names.length, 'enrolled count').to.be.gte(DAY_PATTERNS.length + 1); + activeNames = names.slice(0, DAY_PATTERNS.length); + expectedAmountByName = Object.fromEntries( + activeNames.map((name, i) => [name, expectedAmount(DAY_PATTERNS[i])]), + ); + + // Build the per-row daysByText covering ALL enrolled beneficiaries — + // non-active ones get a zero array so they're excluded from the payroll. + const daysByText = Object.fromEntries(names.map((name) => { + const idx = activeNames.indexOf(name); + return [name, idx >= 0 ? DAY_PATTERNS[idx] : ZERO_DAYS]; + })); + cy.then(() => cy.enterProjectTimeEntriesPerBeneficiary(projectPath, daysByText)); + }); + + cy.then(() => cy.updateProjectStatus(projectPath, 'Completed')); + + cy.createPaymentPlan({ + code: ppCode, + name: ppName, + benefitPlanCode: programCode, + benefitPlanName: programName, + calculationRule: CALC_RULES.TIMESHEET, + calculationParams: { 'Base Day Rate': String(baseDayRate) }, + dateValidFrom: getDateOffset(0), + }); + + cy.ensurePaymentCycleTaskGroup(); + cy.createPaymentCycle({ + code: cycleCode, + startDate: getDateOffset(0), + endDate: getDateOffset(30), + status: 'ACTIVE', + }); + cy.approveLatestPaymentCycleTask(); + + cy.createPayroll({ + name: payrollName, + paymentPlanCode: ppCode, + paymentPlanName: ppName, + paymentCycleCode: cycleCode, + paymentMethod: 'StrategyOfflinePayment', + dateValidFrom: getDateOffset(0), + dateValidTo: getDateOffset(30), + }); + cy.approveAcceptPayrollTask(payrollName); + cy.logout(); + }); + + after(() => { + cy.login(); + cy.deletePaymentPlan(ppName); + if (projectPath) cy.deleteProject(projectPath); + cy.deleteProgram(programName); + cy.logout(); + }); + + beforeEach(() => { + cy.login(); + }); + + it('downloads the template and asserts per-row Amount = days × BDR', () => { + cy.openPayrollForViewFromList(payrollName); + cy.assertPayrollDetailFields({ name: payrollName, status: PAYROLL_STATUS.APPROVE_FOR_PAYMENT }); + + cy.downloadReconciliationFromUI().then(({ rows }) => { + // Beneficiaries with zero days are excluded from the payroll, so the + // CSV row count must equal exactly the active cohort size. + expect(rows).to.have.length(activeNames.length); + + const downloadedNames = rows.map((r) => `${r['First Name']} ${r['Last Name']}`).sort(); + expect(downloadedNames).to.deep.eq([...activeNames].sort()); + + rows.forEach((row) => { + const fullName = `${row['First Name']} ${row['Last Name']}`; + expect(parseFloat(row.Amount), `Amount for ${fullName}`) + .to.eq(expectedAmountByName[fullName]); + expect(row.Status).to.eq('ACCEPTED'); + expect(row.Paid).to.eq(''); + }); + }); + }); + + it('uploads partial reconciliation, then completes, asserting summary at each stage', () => { + cy.openPayrollForViewFromList(payrollName); + + cy.downloadReconciliationFromUI().then(({ rows }) => { + // Stage 1: mark only the first half (alphabetical by name) as paid. + const fullName = (r) => `${r['First Name']} ${r['Last Name']}`; + const half = Math.ceil(rows.length / 2); + const partialPaidNames = new Set( + rows.slice().sort((a, b) => fullName(a).localeCompare(fullName(b))).slice(0, half).map(fullName), + ); + const totalAmount = rows.reduce((acc, r) => acc + parseFloat(r.Amount), 0); + const partialDelivered = rows + .filter((r) => partialPaidNames.has(fullName(r))) + .reduce((acc, r) => acc + parseFloat(r.Amount), 0); + + const partialRows = rows.map((r) => (partialPaidNames.has(fullName(r)) + ? { ...r, Paid: 'Yes', Receipt: `RCT-${r.Code}-1` } + : r)); + + cy.buildReconciliationCsv(partialRows).then((csvText) => { + cy.uploadReconciliationFromUI(payrollName, csvText); + }); + + // Partial reconciliation: do NOT click Approve and Close yet — leave + // the payroll at APPROVE_FOR_PAYMENT and verify the summary reports + // the partial counts. The CSV upload alone does not change payroll + // status; only "Approve and Close" + task approval can advance it. + cy.openPayrollForViewFromList(payrollName); + cy.assertMuiInput('Status', PAYROLL_STATUS.APPROVE_FOR_PAYMENT); + cy.assertReconciliationSummary(payrollName, { + selected: half, + total: rows.length, + totalAmount, + totalDelivered: partialDelivered, + }); + + // Stage 2: re-download (now contains updated statuses for the half) and + // mark the remaining rows as paid. + cy.openPayrollForViewFromList(payrollName); + cy.downloadReconciliationFromUI().then(({ rows: rows2 }) => { + const finalRows = rows2.map((r) => ({ + ...r, + Paid: 'Yes', + Receipt: r.Receipt || `RCT-${r.Code}-2`, + })); + cy.buildReconciliationCsv(finalRows).then((csvText) => { + cy.uploadReconciliationFromUI(payrollName, csvText); + }); + }); + + // After the stage-2 upload all BenefitConsumption rows are RECONCILED, + // but the payroll itself is still APPROVE_FOR_PAYMENT. + cy.approveAndClosePayrollFromSummary(payrollName); + cy.approvePayrollReconciliationTask(payrollName); + + cy.openPayrollForViewFromList(payrollName); + cy.assertMuiInput('Status', PAYROLL_STATUS.RECONCILED); + }); + }); +}); diff --git a/cypress/e2e/payroll.cy.js b/cypress/e2e/payroll.cy.js new file mode 100644 index 0000000..7c0896d --- /dev/null +++ b/cypress/e2e/payroll.cy.js @@ -0,0 +1,421 @@ +import { getTimestamp } from '../support/utils'; +import { TIMEOUTS } from '../support/constants'; + +describe('Payroll workflows', () => { + const suiteTimestamp = getTimestamp(); + const getDateOffset = (days) => { + const date = new Date(); + date.setDate(date.getDate() + days); + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = date.getFullYear(); + return `${day}-${month}-${year}`; + }; + + // Codes must be ≤ 8 characters (backend limit for program/payment-plan codes). + const ts = Date.now(); + const programCode = `PR${ts.toString().slice(-6)}`; + const programName = `E2E Payroll Program ${suiteTimestamp}`; + const ppCode = `PP${ts.toString().slice(-6)}`; + const ppName = `E2E Payroll Plan ${suiteTimestamp}`; + // Payment cycle codes have no documented 8-char limit; use a longer unique value. + const cycleCode = `PCY${ts.toString().slice(-5)}`; + + const createdPayrolls = new Set(); + + const payrollData = (label) => { + const timestamp = getTimestamp(); + return { + name: `E2E Payroll ${label} ${timestamp}`, + paymentPlanCode: ppCode, + paymentPlanName: ppName, + paymentCycleCode: cycleCode, + dateValidFrom: getDateOffset(0), + dateValidTo: getDateOffset(30), + // paymentMethod is omitted → first available method is selected + }; + }; + + const trackPayroll = (name) => { + createdPayrolls.add(name); + }; + + before(() => { + cy.loginAdminInterface(); + cy.setModuleConfig('fe-core', 'menu-config-sp.json'); + cy.setModuleConfig('social_protection', 'social-protection-config.json'); + cy.setModuleConfig('individual', 'individual-config-minimal.json'); + cy.logoutAdminInterface(); + + cy.login(); + cy.createProgram(programCode, programName, '50', 'INDIVIDUAL'); + cy.createPaymentPlan({ + code: ppCode, + name: ppName, + benefitPlanName: programName, + dateValidFrom: getDateOffset(0), + dateValidTo: getDateOffset(365), + }); + + // The PaymentCyclePicker in the payroll form shows only approved ACTIVE cycles. + // ACTIVE cycle creation is routed through through a maker-checker task workflow — the cycle is NOT immediately + // ACTIVE; a task must be approved first. We work around this by: + // 1. Ensuring a task group exists that auto-assigns PaymentCycleService + // tasks (so the task status becomes ACCEPTED, not RECEIVED). + // 2. Creating the cycle with status ACTIVE (which creates the task). + // 3. Approving the task via the UI so the cycle is created as ACTIVE. + cy.ensurePaymentCycleTaskGroup(); + cy.createPaymentCycle({ + code: cycleCode, + startDate: getDateOffset(0), + endDate: getDateOffset(30), + status: 'ACTIVE', + }); + cy.approveLatestPaymentCycleTask(); + cy.logout(); + }); + + after(() => { + cy.login(); + // Only PENDING_APPROVAL payrolls have an enabled delete button in the UI. + // Tests that change the status are responsible for their own cleanup. + Array.from(createdPayrolls).forEach((name) => { + cy.deletePayrollFromList(name); + }); + cy.deletePaymentPlan(ppName); + cy.deleteProgram(programName); + // Payment cycles have no UI delete; cycleCode records accumulate in the DB. + cy.logout(); + }); + + beforeEach(() => { + cy.login(); + }); + + it('validates required fields before allowing payroll creation', () => { + cy.openCreatePayroll(); + cy.assertSaveDisabled(); + }); + + it('creates a payroll successfully', () => { + const payroll = payrollData('Create'); + + cy.createPayroll(payroll); + trackPayroll(payroll.name); + + cy.filterPayrolls({ name: payroll.name }); + cy.assertPayrollRowVisible({ name: payroll.name }); + + // Open detail page and verify all fields were persisted. + cy.openPayrollForViewFromList(payroll.name); + cy.assertPayrollDetailFields({ + name: payroll.name, + status: 'PENDING APPROVAL', + dateValidFrom: payroll.dateValidFrom, + dateValidTo: payroll.dateValidTo, + paymentPlanCode: ppCode, + paymentCycleCode: cycleCode, + }); + // After creation the form is read-only. + cy.assertMuiInputDisabled('Name', payroll.name); + }); + + it('searches payrolls by name', () => { + const targetPayroll = payrollData('Search Target'); + const otherPayroll = payrollData('Search Other'); + + cy.createPayroll(targetPayroll); + cy.createPayroll(otherPayroll); + trackPayroll(targetPayroll.name); + trackPayroll(otherPayroll.name); + + cy.filterPayrolls({ name: targetPayroll.name }); + cy.assertPayrollRowVisible({ name: targetPayroll.name }); + cy.assertPayrollRowNotVisible({ name: otherPayroll.name }); + + cy.filterPayrolls({ name: otherPayroll.name }); + cy.assertPayrollRowVisible({ name: otherPayroll.name }); + cy.assertPayrollRowNotVisible({ name: targetPayroll.name }); + }); + + it('views payroll details from the list', () => { + const payroll = payrollData('View'); + + cy.createPayroll(payroll); + trackPayroll(payroll.name); + + cy.openPayrollForViewFromList(payroll.name); + cy.assertPayrollDetailFields({ + name: payroll.name, + status: 'PENDING APPROVAL', + dateValidFrom: payroll.dateValidFrom, + dateValidTo: payroll.dateValidTo, + paymentPlanCode: ppCode, + paymentCycleCode: cycleCode, + }); + // Detail page should be read-only after creation. + cy.assertMuiInputDisabled('Name', payroll.name); + }); + + it('deletes a PENDING_APPROVAL payroll', () => { + const payroll = payrollData('Delete'); + + cy.createPayroll(payroll); + // Not tracked: deleted below, so no after() cleanup needed. + + cy.deletePayrollFromList(payroll.name); + cy.filterPayrolls({ name: payroll.name }); + cy.assertPayrollRowNotVisible({ name: payroll.name }); + }); + + it('shows a newly-created payroll in the pending payrolls list', () => { + const payroll = payrollData('Pending'); + + cy.createPayroll(payroll); + trackPayroll(payroll.name); + cy.filterPayrolls({ name: payroll.name, visitPending: true }); + cy.assertPayrollRowVisible({ name: payroll.name }); + }); + + it('verifies a newly-created payroll has PENDING APPROVAL status', () => { + const payroll = payrollData('Status Check'); + + cy.createPayroll(payroll); + trackPayroll(payroll.name); + + cy.openPayrollForViewFromList(payroll.name); + cy.assertPayrollDetailFields({ + name: payroll.name, + status: 'PENDING APPROVAL', + dateValidFrom: payroll.dateValidFrom, + }); + }); + + it('opens and closes the reconciliation summary dialog from the pending list', () => { + const payroll = payrollData('Reconcile Dialog'); + + cy.createPayroll(payroll); + trackPayroll(payroll.name); + + cy.openPayrollPendingSummary(payroll.name); + cy.contains('button', 'Close').click(); + cy.contains('View Reconciliation Summary:').should('not.exist'); + }); + + it('end-to-end: payment plan → payment cycle → payroll integration', () => { + // This test verifies the full cross-domain flow using the prerequisites + // already created in before(). A new payroll is created and verified. + const payroll = payrollData('Integration'); + + cy.createPayroll(payroll); + trackPayroll(payroll.name); + + // Verify payroll appears in main list. + cy.filterPayrolls({ name: payroll.name }); + cy.assertPayrollRowVisible({ name: payroll.name }); + + // Verify payroll appears in pending list (filter by name to avoid pagination). + cy.visit('/front/payrollsPending'); + cy.contains('Payrolls Found'); + cy.enterMuiInput('Name', payroll.name); + cy.aliasGraphqlQuery('payroll(', 'pendingPayrollRefresh'); + cy.contains('button', 'Search').click(); + cy.awaitSearcherRefresh('pendingPayrollRefresh'); + cy.assertPayrollRowVisible({ name: payroll.name }); + + // Verify detail page shows correct associations. + cy.openPayrollForViewFromList(payroll.name); + cy.assertPayrollDetailFields({ + name: payroll.name, + status: 'PENDING APPROVAL', + dateValidFrom: payroll.dateValidFrom, + dateValidTo: payroll.dateValidTo, + paymentPlanCode: ppCode, + paymentCycleCode: cycleCode, + }); + cy.assertMuiInputDisabled('Name', payroll.name); + }); + + it('filters payrolls by status', () => { + const payroll = payrollData('StatusFilter'); + + cy.createPayroll(payroll); + trackPayroll(payroll.name); + + // All new payrolls are PENDING_APPROVAL. The status filter dropdown uses + // translated display labels (spaces, not underscores). + cy.filterPayrolls({ name: payroll.name, status: 'PENDING APPROVAL' }); + cy.assertPayrollRowVisible({ name: payroll.name }); + + // Filter by a different status — the payroll should be excluded. + cy.filterPayrolls({ name: payroll.name, status: 'APPROVE FOR PAYMENT' }); + cy.assertPayrollRowNotVisible({ name: payroll.name }); + }); + + it('resets payroll filters and restores full list', () => { + cy.visit('/front/payrolls'); + cy.contains(/\d+ Payrolls Found/, { timeout: TIMEOUTS.BACKEND_VALIDATION }); + + cy.enterMuiInput('Name', 'FAKE_NAME'); + + cy.resetPayrollFilters(); + + cy.contains('label', 'Name') + .siblings('.MuiInputBase-root') + .find('input') + .should('have.value', ''); + }); +}); + +// --- Timesheet Calcrule Payroll Integration --- + +describe('Timesheet calcrule payroll', () => { + const suiteTimestamp = getTimestamp(); + const getDateOffset = (days) => { + const date = new Date(); + date.setDate(date.getDate() + days); + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = date.getFullYear(); + return `${day}-${month}-${year}`; + }; + + const ts = Date.now(); + const tsProgramCode = `TS${ts.toString().slice(-6)}`; + const tsProgramName = `E2E Timesheet Program ${suiteTimestamp}`; + const tsActivityName = `E2E TS Activity ${suiteTimestamp}`; + const tsProjectName = `E2E Timesheet Project ${suiteTimestamp}`; + const tsPpCode = `TP${ts.toString().slice(-6)}`; + const tsPpName = `E2E Timesheet Plan ${suiteTimestamp}`; + const tsCycleCode = `TC${ts.toString().slice(-5)}`; + const baseDayRate = '50'; + let projectPath = null; + + const beneficiarySchema = { + $id: 'https://example.com/beneficiares.schema.json', + type: 'object', + title: 'Timesheet Beneficiary Schema', + $schema: 'http://json-schema.org/draft-04/schema#', + properties: { + able_bodied: { type: 'boolean', description: 'Able bodied flag' }, + educated_level: { type: 'string', description: 'Education level' }, + number_of_children: { type: 'integer', description: 'Number of children' }, + }, + }; + + const createdPayrolls = new Set(); + const trackPayroll = (name) => createdPayrolls.add(name); + + before(() => { + cy.loginAdminInterface(); + cy.setModuleConfig('fe-core', 'menu-config-sp.json'); + cy.setModuleConfig('social_protection', 'social-protection-config.json'); + cy.setModuleConfig('individual', 'individual-config-minimal.json'); + // Use a unique activity name to avoid foreign key conflicts with old projects. + cy.visit('/api/admin'); + cy.contains('a', 'Activities').click(); + cy.contains('a', 'Add Activity').click(); + cy.get('input[name="name"]').type(tsActivityName); + cy.get('input[value="Save"]').click(); + cy.logoutAdminInterface(); + + cy.login(); + + // 1. Create program + cy.createProgram(tsProgramCode, tsProgramName, '50', 'INDIVIDUAL', beneficiarySchema); + + // 2. Enroll individuals directly as Active. + cy.configureDefaultEnrollmentCriteria( + tsProgramName, 'Active', + 'Able bodied', 'Exact', 'False', + ); + cy.enrollIndividualBeneficiariesIntoProgram( + tsProgramName, tsProgramCode, 'Active', + 'Able bodied', 'Exact', 'False', + ); + + // 3. Create project under the program + cy.createProject( + tsProgramName, tsProjectName, tsActivityName, + 'R1 Region 1', null, '10', '3', + ); + cy.url().then((url) => { projectPath = new URL(url).pathname; }); + + // 4. Assign Active beneficiaries to the project + cy.then(() => cy.assignBeneficiariesToProject(projectPath)); + + // 5. Enter time entries (100% for all days) + cy.then(() => cy.enterProjectTimeEntries(projectPath, 100)); + + // 6. Mark project as COMPLETED + cy.then(() => cy.updateProjectStatus(projectPath, 'Completed')); + + // 7. Create payment plan with timesheet calcrule + cy.createPaymentPlan({ + code: tsPpCode, + name: tsPpName, + benefitPlanName: tsProgramName, + calculationRule: 'Calculation rule: timesheet', + calculationParams: { 'Base Day Rate': baseDayRate }, + dateValidFrom: getDateOffset(0), + }); + + // 8. Create ACTIVE payment cycle via task approval + cy.ensurePaymentCycleTaskGroup(); + cy.createPaymentCycle({ + code: tsCycleCode, + startDate: getDateOffset(0), + endDate: getDateOffset(30), + status: 'ACTIVE', + }); + cy.approveLatestPaymentCycleTask(); + + cy.logout(); + }); + + after(() => { + cy.login(); + Array.from(createdPayrolls).forEach((name) => { + cy.deletePayrollFromList(name); + }); + cy.deletePaymentPlan(tsPpName); + if (projectPath) { + cy.deleteProject(projectPath); + } + cy.deleteProgram(tsProgramName); + cy.logout(); + }); + + beforeEach(() => { + cy.login(); + }); + + it('creates a payroll with timesheet-based payment plan', () => { + const payrollName = `E2E Timesheet Payroll ${suiteTimestamp}`; + const payroll = { + name: payrollName, + paymentPlanCode: tsPpCode, + paymentPlanName: tsPpName, + paymentCycleCode: tsCycleCode, + dateValidFrom: getDateOffset(0), + dateValidTo: getDateOffset(30), + }; + + cy.createPayroll(payroll); + trackPayroll(payrollName); + + cy.filterPayrolls({ name: payrollName }); + cy.assertPayrollRowVisible({ name: payrollName }); + + cy.openPayrollForViewFromList(payrollName); + cy.assertPayrollDetailFields({ + name: payrollName, + status: 'PENDING APPROVAL', + dateValidFrom: payroll.dateValidFrom, + dateValidTo: payroll.dateValidTo, + paymentPlanCode: tsPpCode, + paymentCycleCode: tsCycleCode, + }); + cy.assertMuiInputDisabled('Name', payrollName); + }); +}); diff --git a/cypress/fixtures/individuals.csv b/cypress/fixtures/individuals.csv index 9ef46a4..3ad210d 100644 --- a/cypress/fixtures/individuals.csv +++ b/cypress/fixtures/individuals.csv @@ -99,3 +99,23 @@ Carla,Jones,2002-11-30,CNCODP,0,NOT RELATED,,,False,none,2 William,Barnes,1981-09-23,CNCODP,0,NOT RELATED,,,True,primary,1 Jason,Clark,1971-01-14,CNCODP,0,SISTER,,,True,primary,0 Brad,King,1968-07-21,CNCODP,0,GRANDMOTHER,,,False,tertiary,0 +Anna,Peterson,1985-03-15,,1,HEAD,Rachla,R1D1M1V1,True,primary,3 +David,Johnson,1978-08-22,,1,HEAD,Rachla,R1D1M1V1,False,secondary,1 +Sarah,Williams,1990-06-10,,1,HEAD,Agdo,R1D1M1V3,True,primary,2 +James,Brown,1965-12-01,,1,HEAD,Agdo,R1D1M1V3,False,tertiary,0 +Emily,Davis,1992-04-18,,1,HEAD,Jamula,R1D1M2V1,True,primary,4 +Robert,Miller,1975-11-30,,1,HEAD,Jamula,R1D1M2V1,False,none,1 +Laura,Wilson,1988-07-25,,1,HEAD,Jobla,R1D1M4V1,True,secondary,2 +Daniel,Taylor,1970-02-14,,1,HEAD,Jobla,R1D1M4V1,False,primary,3 +Jessica,Anderson,1995-09-08,,1,HEAD,Holobo,R1D2M1V1,True,primary,0 +Thomas,Martinez,1982-05-20,,1,HEAD,Holobo,R1D2M1V1,False,tertiary,1 +Rachel,Garcia,1993-01-12,,1,HEAD,Raberjab,R1D2M1V3,True,primary,5 +Steven,Robinson,1968-10-05,,1,HEAD,Raberjab,R1D2M1V3,False,secondary,2 +Nicole,Lee,1987-03-28,,1,HEAD,Rachla,R1D1M1V1,True,primary,1 +Andrew,Walker,1972-07-15,,1,HEAD,Rachla,R1D1M1V1,False,none,0 +Megan,Hall,1991-11-22,,1,HEAD,Agdo,R1D1M1V3,True,primary,3 +Christopher,Allen,1980-04-09,,1,HEAD,Agdo,R1D1M1V3,False,primary,2 +Amanda,Young,1986-08-17,,1,HEAD,Jamula,R1D1M2V1,True,tertiary,1 +Brian,King,1974-06-30,,1,HEAD,Jamula,R1D1M2V1,False,primary,4 +Stephanie,Wright,1989-12-03,,1,HEAD,Jobla,R1D1M4V1,True,secondary,0 +Timothy,Lopez,1977-09-14,,1,HEAD,Jobla,R1D1M4V1,False,primary,2 diff --git a/cypress/support/commands.js b/cypress/support/commands.js deleted file mode 100644 index 0dca391..0000000 --- a/cypress/support/commands.js +++ /dev/null @@ -1,1008 +0,0 @@ -const getTodayFormatted = () => { - const today = new Date(); - const day = String(today.getDate()).padStart(2, '0'); - const month = String(today.getMonth() + 1).padStart(2, '0'); - const year = today.getFullYear(); - return `${day}-${month}-${year}`; -}; - -Cypress.Commands.add('login', () => { - cy.visit('/front'); - - cy.get('body', { timeout: 15000 }).then(($body) => { - const hasLogoutButton = $body.find('button[title="Log out"]').length > 0; - if (hasLogoutButton) { - return; - } - - cy.fixture('cred').then((cred) => { - cy.visit('/front/login'); - cy.get('input[type="text"]', { timeout: 15000 }).first().clear().type(cred.username); - cy.get('input[type="password"]', { timeout: 15000 }).first().clear().type(cred.password); - cy.get('button[type="submit"]').click(); - cy.url({ timeout: 15000 }).should('not.include', '/front/login'); - }); - }); - - cy.contains('Welcome Admin Admin!', { timeout: 15000 }).should('be.visible'); -}) - -Cypress.Commands.add('logout', () => { - cy.visit('/front'); - cy.get('button[title="Log out"]').click() - cy.contains('label', 'Username') -}) - -Cypress.Commands.add('loginAdminInterface', () => { - cy.visit('/api/admin'); - cy.fixture('cred').then((cred) => { - cy.get('input[type="text"]').type(cred.username) - cy.get('input[type="password"]').type(cred.password) - cy.get('input[type="submit"]').click() - cy.contains('Site administration').should('be.visible') - }) -}) - -Cypress.Commands.add('logoutAdminInterface', () => { - cy.visit('/api/admin'); - cy.contains('button', 'Log out').click() -}) - -Cypress.Commands.add('deleteModuleConfig', (moduleName) => { - cy.visit('/api/admin/core/moduleconfiguration/'); - - cy.get('body').then(($body) => { - if ($body.text().includes('0 module configurations')) { - Cypress.log({ - name: 'deleteModuleConfig', - message: 'No module configurations found, skipping deletion.', - }); - } else { - cy.get('table#result_list').then(($table) => { - const configLink = $table.find(`a:contains("${moduleName}")`); - - if (configLink.length) { - cy.wrap(configLink).click(); - cy.contains('a.deletelink', 'Delete').click(); - cy.get('input[type="submit"][value*="Yes"]').click(); - cy.contains(`a:contains("${moduleName}")`).should('not.exist'); - } else { - Cypress.log({ - name: 'deleteModuleConfig', - message: `Module Configuration named ${moduleName} not found, nothing to delete.`, - }); - } - }); - } - }); -}); - -Cypress.Commands.add('shouldHaveMenuItemsInOrder', (expectedMenuNames) => { - cy.get('div[role="button"]') - .filter(':visible') - .should(($buttons) => { - expect($buttons).to.have.length(expectedMenuNames.length); - - // Check each sub menu item text and order - expectedMenuNames.forEach((itemText, index) => { - expect($buttons.eq(index)).to.contain(itemText); - }); - }); -}) - -Cypress.Commands.add('deleteActivities', (activityNames) => { - cy.visit('/api/admin/social_protection/activity/'); - cy.get('body').then(($body) => { - let checkedAny = false; - activityNames.forEach(activityName => { - if ($body.find(`td.field-name:contains("${activityName}")`).length) { - // Check the checkbox in the same row as the activity name - cy.contains('td.field-name', activityName) - .parent('tr') - .find('input[type="checkbox"]') - .check(); - checkedAny = true; - } - }); - - if (!checkedAny) { - Cypress.log({ - name: 'deleteActivity', - message: `Activities not found, nothing to delete`, - }); - return - } - - // Select the delete action and submit - cy.get('select[name="action"]').select('delete_selected'); - cy.get('button[type="submit"]').contains('Go').click(); - - // Confirm the deletion - cy.get('input[type="submit"][value*="Yes"]').click() - - // Verify deletion - activityNames.forEach(activityName => { - cy.contains('td.field-name', activityName).should('not.exist'); - }); - }); -}); - -Cypress.Commands.add('createActivities', (activities) => { - cy.deleteActivities(activities) - - activities.forEach(activityName => { - cy.contains('a', 'Activities').click() - cy.contains('a', 'Add Activity').click() - cy.get('input[name="name"]').type(activityName) - cy.get('input[value="Save"]').click() - cy.contains('td.field-name', activityName) - }) -}) - -Cypress.Commands.add('deleteProject', (projectPath) => { - cy.visit(projectPath); - cy.get('button[title="Delete"]').click(); - cy.contains('button', 'Ok').click(); - - // Check redirect - cy.location('pathname').should('not.include', projectPath); - - // Check last journal message - cy.get('ul.MuiList-root li').first().click() - cy.contains(`Delete project`).should('exist') - cy.contains('Failed to delete').should('not.exist') -}) - -Cypress.Commands.add('createProject', ( - programName, - projectName, - activityName, - regionName, - districtName, - targetBeneficiaries, - workingDays, -) => { - cy.visit('/front/benefitPlans'); - cy.openProgramForEditFromList(programName) - cy.contains('button', 'Projects').click() - cy.contains('button', 'Create Project').click() - cy.contains('h6', 'Project details') - - cy.enterMuiInput('Name', projectName) - - cy.chooseMuiAutocomplete('Activity', activityName) - - cy.chooseMuiAutocomplete('Location', regionName) - if (districtName) { - cy.contains('li', districtName).click() - } - - cy.enterMuiInput('Target Beneficiaries', targetBeneficiaries) - - cy.enterMuiInput('Working Days', workingDays) - - cy.get('[title="Save"] button').click() - - // Wait for creation to complete - cy.get('ul.MuiList-root li div[role="progressbar"]').should('exist') - cy.get('ul.MuiList-root li div[role="progressbar"]').should('not.exist') - - // Check last journal message - cy.get('ul.MuiList-root li').first().click() - cy.contains(`Create project ${projectName}`).should('exist') - cy.contains('Failed to create').should('not.exist') -}) - -Cypress.Commands.add('deleteProgram', (programName) => { - cy.visit('/front/benefitPlans'); - cy.contains('tfoot', 'Rows Per Page').should('be.visible') - - cy.get('body').then(($body) => { - const programRows = $body.find(`td:contains("${programName}")`).closest('tr'); - - if (programRows.length > 0) { - cy.log(`Found ${programRows.length} program(s) to delete`); - - programRows.each((_, row) => { - cy.wrap(row).within(() => { - // Find and click the Delete button in this row - cy.get('button[title="Delete"]') - .click({force: true}); - }); - - // Confirm deletion in dialog - cy.contains('button', 'Ok') - .should('be.visible') - .click(); - - // Wait for deletion to complete - cy.get('ul.MuiList-root li div[role="progressbar"]').should('exist') - - // Verify deletion in expanded journal drawer - cy.get('.MuiDrawer-paperAnchorRight button') - .first() - .click(); - - cy.get('ul.MuiList-root li') - .first() - .should('contain', 'Delete program'); - // .should('contain', `Delete program ${programName}`); //TODO: switch to this after fix - - // Close journal drawer - cy.get('.MuiDrawer-paperAnchorRight button') - .first() - .click(); - }); - } else { - Cypress.log({ - name: 'deleteProgram', - message: `No programs found with name "${programName}"`, - }); - } - }); -}); - -Cypress.Commands.add('createProgram', (programCode, programName, maxBeneficiaries, programType) => { - cy.visit('/front/benefitPlans'); - cy.get('[title="Create"] button').click() - - cy.enterMuiInput('Code', programCode) - - cy.enterMuiInput('Name', programName) - - cy.contains('label', 'Date from') - .parent() - .click() - cy.contains('button', 'OK') - .click() - - cy.contains('label', 'Date to') - .parent() - .click() - cy.contains('button', 'OK') - .click() - - cy.enterMuiInput('Max Beneficiaries', maxBeneficiaries) - - cy.contains('label', 'Type') - .parent() - .click() - cy.contains('li[role="option"]', programType) - .click() - - cy.get('[title="Save changes"] button').click() - - // Wait for creation to complete - cy.get('ul.MuiList-root li div[role="progressbar"]').should('exist') - cy.get('ul.MuiList-root li div[role="progressbar"]').should('not.exist') - - // Check last journal message - cy.get('ul.MuiList-root li').first().click() - cy.contains('Create program').should('exist') - cy.contains('Failed to create').should('not.exist') -}) - -Cypress.Commands.add('openProgramForEditFromList', (programName) => { - cy.contains('tfoot', 'Rows Per Page') - // Search by name to ensure the program is visible regardless of pagination - cy.enterMuiInput('Name', programName); - cy.contains('button', 'Search').click(); - cy.contains('tfoot', 'Rows Per Page') - cy.contains('td', programName) - .parent('tr').within(() => { - // click on edit button - cy.get('a.MuiIconButton-root').click() - }) - cy.assertMuiInput('Name', programName) -}) - -Cypress.Commands.add('checkProgramUpdateCompleted', (programName) => { - // Wait for update to complete - cy.get('ul.MuiList-root li div[role="progressbar"]').should('exist') - cy.get('ul.MuiList-root li div[role="progressbar"]').should('not.exist') - - // Check last journal message - cy.get('ul.MuiList-root li').first().click() - cy.contains('Update program').should('exist') - cy.contains('Failed to update').should('not.exist') -}) - -Cypress.Commands.add( - 'checkProgramFieldValues', - ( - programCode, - programName, - maxBeneficiaries, - programType, - institution='', - description='', - ) => { - cy.assertMuiInput('Code', programCode) - cy.assertMuiInput('Name', programName) - const today = getTodayFormatted() - cy.assertMuiInput('Date from', today) - cy.assertMuiInput('Date to', today) - cy.assertMuiInput('Max Beneficiaries', maxBeneficiaries) - cy.assertMuiInput('Institution', institution) - cy.assertMuiInput('Description', description, 'textarea') -}) - -Cypress.Commands.add( - 'checkProgramFieldValuesInListView', - (programCode, programName, maxBeneficiaries, programType) => { - - cy.contains('tfoot', 'Rows Per Page') - cy.contains('td', programName).should('exist') - cy.contains('td', programName) - .parent('tr').within(() => { - cy.contains('td', programCode) - cy.contains('td', programType) - cy.contains('td', maxBeneficiaries) - cy.contains('td', new Date().toISOString().substring(0, 10)) - }) -}) - -Cypress.Commands.add('uploadIndividualsCSV', (numIndividuals) => { - cy.task('updateCSV', { numIndividuals }).then(() => { - cy.contains('li', 'UPLOAD').click() - - cy.get('input[type="file"]').attachFile('tmp_individuals.csv'); - - cy.chooseMuiSelect('Workflow', 'Python Import Individuals') - cy.contains('button', 'Upload Individuals').click(); - - cy.contains('button', 'Upload Individuals').should('not.exist') - cy.contains('button', 'Uploading...').should('be.disabled') - }) -}) - -Cypress.Commands.add('ensureSufficientIndividuals', (expectedNumIndividuals) => { - cy.visit('/front/individuals') - cy.getItemCount('Individual').then(count => { - const numToAdd = expectedNumIndividuals - count; - if (numToAdd <= 0) { - Cypress.log({ - name: 'ensureSufficientIndividuals', - message: `Found ${count} which is more than ${expectedNumIndividuals}, no need to add additional`, - }); - return - } - - cy.visit('/front/individuals') - cy.uploadIndividualsCSV(numToAdd) - - cy.wait(100*numToAdd) // group creation takes time - - cy.visit('/front/individuals') - cy.getItemCount("Individual").then(newCount => { - expect(newCount).to.be.gte(expectedNumIndividuals); - }); - }) -}) - -Cypress.Commands.add('ensureSufficientHouseholds', (expectedNumGroups) => { - cy.visit('/front/groups') - cy.getItemCount('Group').then(numGroups => { - const numGroupsToAdd = expectedNumGroups - numGroups; - if (numGroupsToAdd <= 0) { - Cypress.log({ - name: 'ensureSufficientHouseholds', - message: `Found ${numGroups} which is more than ${expectedNumGroups}, no need to add additional`, - }); - return - } - - const numIndividualsToAdd = numGroupsToAdd * 5 - cy.visit('/front/individuals') - cy.uploadIndividualsCSV(numIndividualsToAdd) - - cy.wait(100*numIndividualsToAdd) // group creation takes time - - cy.visit('/front/groups') - cy.getItemCount("Group").then(newCount => { - expect(newCount).to.be.gte(expectedNumGroups); - }); - }) -}) - -Cypress.Commands.add('ensurePermissiveTaskGroup', () => { - cy.visit('/front/tasks/groups'); - - cy.contains('Task Groups Found') - cy.get('table').then(($table) => { - const hasAnyRow = $table.find('tbody tr td:first-child') - .toArray() - .some((td) => td.innerText.trim() === 'any'); - - if (!hasAnyRow) { - cy.get('[title="Create"] button').click(); - - cy.enterMuiInput('Code', 'any'); - cy.chooseMuiSelect('Policy Status', 'ANY'); - cy.chooseMuiAutocomplete('Task Executors', 'Admin Admin'); - - cy.get('[title="Save changes"] button').click(); - - // Wait for creation to complete - cy.get('ul.MuiList-root li div[role="progressbar"]').should('exist'); - - // Verify creation in expanded journal drawer - cy.get('.MuiDrawer-paperAnchorRight button').first().click(); - - cy.get('ul.MuiList-root li').first().should('contain', 'Create task group'); - } else { - cy.log('Permissive task group named any already exists — skipping creation.'); - } - }); -}); - -Cypress.Commands.add('configureDefaultEnrollmentCriteria', ( - programName, status, criterionField, criterionFilter, criterionValue, -) => { - cy.visit('/front/benefitPlans'); - cy.openProgramForEditFromList(programName) - - cy.contains('button', 'Beneficiaries').click() - cy.contains('button', status).click() - - cy.contains(`${status} Beneficiary Enrollment Criteria`) - cy.contains('button', 'Add Filters').click() - - cy.chooseMuiSelect('Field', criterionField) - cy.chooseMuiSelect('Confirm Filters', criterionFilter) - - const isValueSelect = /^(True|False)$/.test(criterionValue); - isValueSelect - ? cy.chooseMuiSelect('Value', criterionValue) - : cy.enterMuiInput('Value', criterionValue); - - cy.get('[title="Save changes"] button').click() - - cy.checkProgramUpdateCompleted() - cy.reload() - - cy.contains('button', 'Beneficiaries').click() - cy.contains('button', status).click() - - cy.assertMuiSelectValue('Field', criterionField); - cy.assertMuiSelectValue('Confirm Filters', criterionFilter); - isValueSelect - ? cy.assertMuiSelectValue('Value', criterionValue) - : cy.assertMuiInput('Value', criterionValue); -}); - -Cypress.Commands.add('enrollBeneficiariesIntoProgram', ( - programName, - programCode, - status, // Active, Potential etc. - criterionField, - criterionFilter, - criterionValue, - entityName, -) => { - cy.chooseMuiAutocomplete('Program', programName) - cy.chooseMuiSelect('Status', status.toUpperCase()) - - cy.assertMuiSelectValue('Field', criterionField); - cy.assertMuiSelectValue('Confirm Filters', criterionFilter); - /^(True|False)$/.test(criterionValue) - ? cy.assertMuiSelectValue('Value', criterionValue) - : cy.assertMuiInput('Value', criterionValue); - - cy.contains('button', 'Preview Enrollment Process').click() - - cy.contains('h6', `Number Of Selected ${entityName}`) - .next('p') - .invoke('text') - .then((text) => { - const num = Number(text.trim()); - cy.wrap(num).as('numEnrolled'); - expect(num).to.be.greaterThan(0); - }); - - cy.contains('button', 'Confirm Enrollment Process').click() - - // confirmation dialog - cy.contains('h2', 'Confirm Enrollment Process') - cy.contains('button', 'Ok').click() - - // The enrollment page doesn't trigger journal update correctly - // so we'd have to reload the page here - cy.reload() - - // Verify enrollment in expanded journal drawer - cy.get('.MuiDrawer-paperAnchorRight button') - .first() - .click(); - - cy.get('ul.MuiList-root li') - .first() - .should('contain', 'Enrollment has been confirmed'); - - // maker-checker approves enrollment - cy.ensurePermissiveTaskGroup() - cy.visit('/front/AllTasks') - cy.contains('tfoot', 'Rows Per Page') - cy.get('tr') - .filter((_, tr) => ( - Cypress.$(tr).find('td:contains("import_valid_items")').length > 0 && - Cypress.$(tr).find('td:contains("RECEIVED")').length > 0 - )) - .first() - .within(() => { - cy.get('td') - .contains(new RegExp(`^${programCode}\\b`)) - .should('exist'); - - cy.get('button[title="View details"]').click(); - }); - - cy.contains('Import Valid Items Task') - cy.chooseMuiAutocomplete('Task Group', 'any'); - cy.get('[title="Save changes"] button').click(); - - cy.contains('div', 'Accept All') - .find('button') - .click(); - - cy.contains('Beneficiary Upload Confirmation') - cy.contains('button', 'Continue').click() - cy.contains('div', 'Accept All') - .find('button').should('be.disabled') - - cy.visit('/front/AllTasks') - cy.get('tr') - .filter((_, tr) => ( - Cypress.$(tr).find('td:contains("import_valid_items")').length > 0 && - Cypress.$(tr).find(`td:contains("${programCode}")`).length > 0 - )) - .first() - .within(() => { - cy.contains('td', 'COMPLETED') - }); - - cy.visit('/front/benefitPlans'); - cy.openProgramForEditFromList(programName) - cy.contains('button', 'Beneficiaries').click() - cy.contains('button', status).click() - - cy.get('@numEnrolled').then((count) => { - if (entityName === 'Groups') { - cy.contains(`${count} Group Beneficiaries`) - } else { - cy.contains(`${count} Beneficiaries`) - } - }); -}) - -Cypress.Commands.add('enrollIndividualBeneficiariesIntoProgram', ( - programName, - programCode, - status, - criterionField, - criterionFilter, - criterionValue, -) => { - cy.ensureSufficientIndividuals(100) - - cy.visit('/front/individuals') - cy.contains('a', 'ENROLLMENT').click() - - cy.enrollBeneficiariesIntoProgram( - programName, programCode, status, - criterionField, criterionFilter, criterionValue, 'Individuals' - ) -}) - -Cypress.Commands.add('enrollGroupBeneficiariesIntoProgram', ( - programName, - programCode, - status, - criterionField, - criterionFilter, - criterionValue, -) => { - cy.ensureSufficientHouseholds(20) - - cy.visit('/front/groups') - cy.contains('a', 'ENROLLMENT').click() - - cy.enrollBeneficiariesIntoProgram( - programName, programCode, status, - criterionField, criterionFilter, criterionValue, 'Groups' - ) -}) - - -Cypress.Commands.add('enterMuiInput', (label, value, inputTag='input') => { - cy.contains('label', label) - .siblings('.MuiInputBase-root') - .find(inputTag) - .first() - .clear({force: true}) - .type(value, {force: true}); -}) - -Cypress.Commands.add('chooseMuiSelect', (label, value) => { - cy.contains('label', label) - .siblings('.MuiInputBase-root') - .find('[role="button"]') - .click() - - cy.contains('[role="listbox"] li', value).as('option') - cy.get('@option').click() -}) - -Cypress.Commands.add('assertMuiInput', (label, value, inputTag='input') => { - cy.contains('label', label) - .siblings('.MuiInputBase-root') - .find(inputTag) - .should('be.visible') - .and('have.value', value); -}) - -Cypress.Commands.add('assertMuiInputDisabled', (label, value=null, inputTag='input') => { - const input = cy.contains('label', label) - .siblings('.MuiInputBase-root') - .find(inputTag) - input.should('be.disabled'); - - if (value) { - input.should('have.value', value) - } -}) - -Cypress.Commands.add('assertMuiSelectValue', (label, value) => { - cy.contains('label', label) - .siblings('.MuiInputBase-root') - .contains(value) -}) - -Cypress.Commands.add('chooseMuiAutocomplete', (label, value = null) => { - cy.contains('label', label) - .siblings('.MuiInputBase-root') - .find('input') - .click() - - const optionSelector = '[role="menu"] li, [role="presentation"] li, [role="listbox"] li, li[role="option"]' - - if (value) { - cy.contains(optionSelector, value).click(); - return - } - - cy.get(optionSelector) - .should('have.length.at.least', 1) - .first() - .click() -}) - -Cypress.Commands.add('setModuleConfig', (moduleName, configFixtureFile) => { - cy.deleteModuleConfig(moduleName) - - cy.contains('a', 'Module configurations').click() - - // Create module config using fixture config file - cy.contains('a', 'Add module configuration').click() - cy.get('input[name="module"]').type(moduleName) - cy.get('select[name="layer"]').select('backend') - cy.get('input[name="version"]').type(1) - - cy.fixture(configFixtureFile).then((config) => { - const configString = JSON.stringify(config, null, 2); - cy.get('textarea[name="config"]') - .type(configString, { - parseSpecialCharSequences: false, - delay: 0 // Type faster - }); - - cy.get('input[value="Save"]').click() - cy.contains("was added successfully") - }) -}) - -Cypress.Commands.add('getItemCount', (itemName) => { - const pattern = new RegExp(`\\d+ ${itemName}s? Found`); - return cy.contains(pattern) - .invoke('text') - .then((text) => { - const match = text.match(new RegExp(`(\\d+)\\s+${itemName}`)); - return parseInt(match?.[1], 10); - }); -}); - -Cypress.Commands.add('getGrievanceCount', () => { - const pattern = /\(\d+\) Grievance\(s\)/; - return cy.contains(pattern) - .invoke('text') - .then((text) => { - const match = text.match(/\((\d+)\) Grievance/); - return parseInt(match?.[1], 10); - }); -}); - -Cypress.Commands.add('createGrievance', (grievanceData) => { - cy.visit('/front/ticket/newTicket'); - - // Required fields - cy.enterMuiInput('Grievance Title', grievanceData.title); - cy.chooseMuiAutocomplete('Category', grievanceData.category); - cy.chooseMuiAutocomplete('Flag', grievanceData.flag); - cy.chooseMuiAutocomplete('Channel', grievanceData.channel); - - // Optional fields - if (grievanceData.priority) { - cy.chooseMuiSelect('Priority', grievanceData.priority); - } - - if (grievanceData.dateOfIncident) { - cy.contains('label', 'Date Of Incident') - .parent() - .find('input') - .type(grievanceData.dateOfIncident); - } - - if (grievanceData.assignedUser) { - cy.chooseMuiAutocomplete('Assigned User', grievanceData.assignedUser); - } - - if (grievanceData.details) { - cy.enterMuiInput('DETAILS OF EVENT', grievanceData.details); - } - - // Reporter type handling - if (grievanceData.reporterType) { - cy.chooseMuiSelect('Reporter Type', grievanceData.reporterType); - - if (grievanceData.reporterType === 'Individual') { - if (grievanceData.benefitPlan) { - cy.chooseMuiAutocomplete('Program', grievanceData.benefitPlan); - } - if (grievanceData.individual) { - cy.chooseMuiAutocomplete('Individual', grievanceData.individual); - } else { - cy.chooseMuiAutocomplete('Individual'); - } - } else if (grievanceData.reporterType === 'Beneficiary') { - if (grievanceData.benefitPlan) { - cy.chooseMuiAutocomplete('Program', grievanceData.benefitPlan); - } - if (grievanceData.beneficiary) { - cy.chooseMuiAutocomplete('BeneficiaryPicker', grievanceData.beneficiary); - } else { - cy.chooseMuiAutocomplete('BeneficiaryPicker'); - } - } else if (grievanceData.reporterType === 'Attending Staff') { - if (grievanceData.attendingStaff) { - cy.chooseMuiAutocomplete('Complainant', grievanceData.attendingStaff); - } else { - cy.chooseMuiAutocomplete('Complainant'); - } - } - } - - // Save the grievance - cy.get('label[role="button"].MuiIconButton-colorPrimary').click(); - - // Wait for creation to complete - cy.get('ul.MuiList-root li div[role="progressbar"]', { timeout: 15000 }).should('exist'); - cy.get('ul.MuiList-root li div[role="progressbar"]', { timeout: 15000 }).should('not.exist'); - - // Check journal for success - cy.get('ul.MuiList-root li').first().click(); - cy.contains(`Created Ticket ${grievanceData.title}`).should('exist'); - cy.contains('Failed to create').should('not.exist'); -}); - -Cypress.Commands.add('updateGrievance', (grievanceCode, updateData, immutableFields = {}) => { - cy.visit('/front/ticket/tickets'); - cy.contains('tfoot', 'Rows Per Page').should('be.visible'); - - // Search for grievance by code - cy.enterMuiInput('Code', grievanceCode); - cy.contains('button', 'Search').click(); - cy.contains('tfoot', 'Rows Per Page').should('be.visible'); - - // Open grievance for edit - cy.contains('td', grievanceCode) - .parent('tr') - .within(() => { - cy.get('button[title="Edit"]').click(); - }); - - if (immutableFields.reporterType) { - cy.assertMuiInputDisabled('Reporter Type', immutableFields.reporterType); - } - - if (immutableFields.reporterFieldLabel) { - cy.assertMuiInputDisabled( - immutableFields.reporterFieldLabel, - immutableFields.reporterFieldValue ?? null - ); - } - - // Update fields (excluding reporter type and reporter info) - if (updateData.title) { - cy.enterMuiInput('Title', updateData.title); - } - - if (updateData.category) { - cy.chooseMuiAutocomplete('Category', updateData.category); - } - - if (updateData.flag) { - cy.chooseMuiAutocomplete('Flag', updateData.flag); - } - - if (updateData.channel) { - cy.chooseMuiAutocomplete('Channel', updateData.channel); - } - - if (updateData.priority) { - cy.chooseMuiSelect('Priority', updateData.priority); - } - - if (updateData.details) { - cy.enterMuiInput('Description', updateData.details); - } - - // Save changes - cy.get('label[role="button"].MuiIconButton-colorPrimary').click(); - - // Wait for update to complete - cy.get('ul.MuiList-root li div[role="progressbar"]', { timeout: 15000 }).should('exist'); - cy.get('ul.MuiList-root li div[role="progressbar"]', { timeout: 15000 }).should('not.exist'); - - // Check journal for success - cy.get('ul.MuiList-root li').first().click(); - cy.contains('updated ticket', { timeout: 10000 }).should('exist'); - cy.contains('Failed to update').should('not.exist'); -}); - -Cypress.Commands.add('resolveGrievance', (grievanceCode, comment = 'Resolved Grievance') => { - cy.searchAndOpenGrievanceForEdit(grievanceCode); - cy.addGrievanceComment(comment); - - // Click the tick mark icon on the first/latest comment to resolve - cy.get('button[title="Resolve grievance with this comment."]').first().click(); - - // Wait for resolve to complete - cy.get('ul.MuiList-root li div[role="progressbar"]', { timeout: 15000 }).should('exist'); - cy.get('ul.MuiList-root li div[role="progressbar"]', { timeout: 15000 }).should('not.exist'); - - // Check journal for success - cy.get('ul.MuiList-root li').first().click(); - cy.contains('Resolve Ticket using comment', { timeout: 10000 }).should('exist'); - cy.contains('Failed').should('not.exist'); -}); - -Cypress.Commands.add('unlockGrievance', (grievanceCode) => { - cy.searchAndOpenGrievanceForEdit(grievanceCode); - - // Click the unlock icon (lock icon in header action area) - cy.get('div[class*="paperHeaderAction"] button.MuiIconButton-root').click(); - - cy.get('ul.MuiList-root li div[role="progressbar"]', { timeout: 15000 }).should('exist'); - cy.get('ul.MuiList-root li div[role="progressbar"]', { timeout: 15000 }).should('not.exist'); - - cy.get('ul.MuiList-root li').first().click(); - cy.contains('Failed').should('not.exist'); -}); - -Cypress.Commands.add('checkGrievanceFieldValues', (title, category, flag, channel, priority = null, details = null) => { - cy.assertMuiInput('Grievance Title', title); - cy.assertMuiInput('Category', category); - cy.assertMuiInput('Flag', flag); - cy.assertMuiInput('Channel', channel); - if (priority) { - cy.assertMuiSelectValue('Priority', priority); - } - - if (details) { - cy.assertMuiInput('Description', details); - } -}); - -Cypress.Commands.add('checkGrievanceFieldValuesInListView', (title, category) => { - cy.contains('tfoot', 'Rows Per Page').should('be.visible'); - - cy.enterMuiInput('Title', title); - cy.contains('button', 'Search').click(); - cy.contains('tfoot', 'Rows Per Page').should('be.visible'); - - cy.contains('td', title).should('exist'); - cy.contains('td', title) - .parent('tr') - .within(() => { - cy.contains('td', category).should('exist'); - }); -}); - -Cypress.Commands.add('getGrievanceCodeFromList', (title) => { - cy.visit('/front/ticket/tickets'); - cy.contains('tfoot', 'Rows Per Page').should('be.visible'); - - cy.enterMuiInput('Title', title); - cy.contains('button', 'Search').click(); - cy.contains('tfoot', 'Rows Per Page').should('be.visible'); - - return cy.contains('td', title) - .parent('tr') - .find('td') - .first() - .invoke('text') - .then((code) => code.trim()); -}); - -Cypress.Commands.add('searchAndOpenGrievanceForEdit', (grievanceCode) => { - cy.visit('/front/ticket/tickets'); - cy.contains('tfoot', 'Rows Per Page').should('be.visible'); - - cy.enterMuiInput('Code', grievanceCode); - cy.contains('button', 'Search').click(); - cy.contains('tfoot', 'Rows Per Page').should('be.visible'); - - cy.contains('td', grievanceCode) - .parent('tr') - .within(() => { - cy.get('button[title="Edit"]').click(); - }); -}); - -Cypress.Commands.add('addGrievanceComment', (commentText, commentData = {}) => { - cy.contains('button', 'Add Comment').click(); - // Use native HTMLInputElement value setter to avoid DOM detachment caused by - // React's per-keystroke re-renders in TicketCommentsPanel (setInterval + controlled input). - cy.contains('label', 'Comment') - .siblings('.MuiInputBase-root') - .find('input') - .first() - .then(($input) => { - const inputEl = $input[0]; - const win = inputEl.ownerDocument.defaultView || window; - const nativeInputValueSetter = Object.getOwnPropertyDescriptor( - win.HTMLInputElement.prototype, 'value' - ).set; - nativeInputValueSetter.call(inputEl, commentText); - inputEl.dispatchEvent(new Event('input', { bubbles: true })); - }); - - if (commentData.reporterType) { - cy.chooseMuiSelect('Reporter Type', commentData.reporterType); - - if (commentData.reporterType === 'Individual') { - if (commentData.benefitPlan) { - cy.chooseMuiAutocomplete('Program', commentData.benefitPlan); - } - if (commentData.individual) { - cy.chooseMuiAutocomplete('Individual', commentData.individual); - } else { - cy.chooseMuiAutocomplete('Individual'); - } - } else if (commentData.reporterType === 'Beneficiary') { - if (commentData.benefitPlan) { - cy.chooseMuiAutocomplete('Program', commentData.benefitPlan); - } - if (commentData.beneficiary) { - cy.chooseMuiAutocomplete('Beneficiary', commentData.beneficiary); - } else { - cy.chooseMuiAutocomplete('Beneficiary'); - } - } else if (commentData.reporterType === 'Attending Staff') { - if (commentData.attendingStaff) { - cy.chooseMuiAutocomplete('Commenter', commentData.attendingStaff); - } else { - cy.chooseMuiAutocomplete('Commenter'); - } - } - } - - cy.contains('button', 'Save').click(); - - // Wait for save mutation to complete before reloading - cy.get('ul.MuiList-root li div[role="progressbar"]', { timeout: 15000 }).should('exist'); - cy.get('ul.MuiList-root li div[role="progressbar"]', { timeout: 15000 }).should('not.exist'); - - // cy.reload(); - cy.contains(commentText).should('exist'); -}); diff --git a/cypress/support/commands/admin.commands.js b/cypress/support/commands/admin.commands.js new file mode 100644 index 0000000..a86c874 --- /dev/null +++ b/cypress/support/commands/admin.commands.js @@ -0,0 +1,137 @@ +export function registerAdminCommands() { + Cypress.Commands.add('deleteModuleConfig', (moduleName) => { + cy.visit('/api/admin/core/moduleconfiguration/'); + + cy.get('body').then(($body) => { + if ($body.text().includes('0 module configurations')) { + Cypress.log({ + name: 'deleteModuleConfig', + message: 'No module configurations found, skipping deletion.', + }); + } else { + cy.get('table#result_list').then(($table) => { + const configLink = $table.find(`a:contains("${moduleName}")`); + + if (configLink.length) { + cy.wrap(configLink).click(); + cy.contains('a.deletelink', 'Delete').click(); + cy.get('input[type="submit"][value*="Yes"]').click(); + cy.contains(`a:contains("${moduleName}")`).should('not.exist'); + } else { + Cypress.log({ + name: 'deleteModuleConfig', + message: `Module Configuration named ${moduleName} not found, nothing to delete.`, + }); + } + }); + } + }); + }); + + Cypress.Commands.add('shouldHaveMenuItemsInOrder', (expectedMenuNames) => { + cy.get('div[role="button"]') + .filter(':visible') + .should(($buttons) => { + expect($buttons).to.have.length(expectedMenuNames.length); + + // Check each sub menu item text and order + expectedMenuNames.forEach((itemText, index) => { + expect($buttons.eq(index)).to.contain(itemText); + }); + }); + }); + + Cypress.Commands.add('deleteActivities', (activityNames) => { + cy.visit('/api/admin/social_protection/activity/'); + cy.get('body').then(($body) => { + let checkedAny = false; + activityNames.forEach((activityName) => { + if ($body.find(`td.field-name:contains("${activityName}")`).length) { + // Check the checkbox in the same row as the activity name + cy.contains('td.field-name', activityName) + .parent('tr') + .find('input[type="checkbox"]') + .check(); + checkedAny = true; + } + }); + + if (!checkedAny) { + Cypress.log({ + name: 'deleteActivity', + message: 'Activities not found, nothing to delete', + }); + return; + } + + // Select the delete action and submit + cy.get('select[name="action"]').select('delete_selected'); + cy.get('button[type="submit"]').contains('Go').click(); + + // Confirm the deletion + cy.get('input[type="submit"][value*="Yes"]').click(); + + // Verify deletion + activityNames.forEach((activityName) => { + cy.contains('td.field-name', activityName).should('not.exist'); + }); + }); + }); + + Cypress.Commands.add('createActivities', (activities) => { + cy.deleteActivities(activities); + + activities.forEach((activityName) => { + cy.contains('a', 'Activities').click(); + cy.contains('a', 'Add Activity').click(); + cy.get('input[name="name"]').type(activityName); + cy.get('input[value="Save"]').click(); + cy.contains('td.field-name', activityName); + }); + }); + + Cypress.Commands.add('setModuleConfig', (moduleName, configFixtureFile) => { + cy.deleteModuleConfig(moduleName); + + cy.contains('a', 'Module configurations').click(); + + // Create module config using fixture config file + cy.contains('a', 'Add module configuration').click(); + cy.get('input[name="module"]').type(moduleName); + cy.get('select[name="layer"]').select('backend'); + cy.get('input[name="version"]').type(1); + + cy.fixture(configFixtureFile).then((config) => { + const configString = JSON.stringify(config, null, 2); + cy.get('textarea[name="config"]') + .type(configString, { + parseSpecialCharSequences: false, + delay: 0, + }); + + cy.get('input[value="Save"]').click(); + cy.contains('was added successfully'); + }); + }); + + Cypress.Commands.add('setFrontendModuleConfig', (moduleName, configFixtureFile) => { + cy.deleteModuleConfig(moduleName); + cy.visit('/api/admin/core/moduleconfiguration/'); + cy.contains('a', 'Add module configuration').click(); + cy.get('input[name="module"]').type(moduleName); + cy.get('select[name="layer"]').select('frontend'); + cy.get('input[name="version"]').type(1); + + cy.fixture(configFixtureFile).then((config) => { + const configString = JSON.stringify(config, null, 2); + cy.get('textarea[name="config"]') + .type(configString, { + parseSpecialCharSequences: false, + delay: 0, + }); + cy.get('input[name="is_exposed"]').check(); + cy.get('input[value="Save"]').click(); + cy.contains('was added successfully'); + }); + }); +} diff --git a/cypress/support/commands/auth.commands.js b/cypress/support/commands/auth.commands.js new file mode 100644 index 0000000..a53a741 --- /dev/null +++ b/cypress/support/commands/auth.commands.js @@ -0,0 +1,87 @@ +export function registerAuthCommands() { + Cypress.Commands.add('login', () => { + // Clear cookies so we always land on a clean login page regardless of + // the state the previous test left the browser in. + cy.clearCookies(); + cy.visit('/front/login'); + + cy.get('body', { timeout: 30000 }) + .should(($body) => { + const loggedIn = $body.find('button[title="Log out"]').length > 0 + || $body.text().includes('Welcome Admin Admin!'); + const onLoginPage = $body.find('input[type="password"]').length > 0 + || $body.find('button').toArray().some((el) => el.textContent.trim() === 'Log In'); + expect( + loggedIn || onLoginPage, + 'frontend authenticated shell or login page should be visible', + ).to.be.true; + }) + .then(($body) => { + const loggedIn = $body.find('button[title="Log out"]').length > 0 + || $body.text().includes('Welcome Admin Admin!'); + + if (loggedIn) { + return; + } + + cy.fixture('cred').then((cred) => { + // Use force:true — a "Session Expired" dialog may cover the + // login form after an admin-interface logout. + cy.get('input[type="text"]', { timeout: 15000 }) + .first() + .clear({ force: true }) + .type(cred.username, { force: true }); + cy.get('input[type="password"]', { timeout: 15000 }) + .first() + .clear({ force: true }) + .type(cred.password, { force: true }); + cy.get('button[type="submit"]').click({ force: true }); + cy.contains('Welcome Admin Admin!', { timeout: 15000 }).should('be.visible'); + }); + }); + }); + + Cypress.Commands.add('logout', () => { + cy.visit('/front'); + + // Wait until the SPA has settled: either the logout button is in the navbar + // (logged in) or the login form is visible (already logged out). + cy.get('body', { timeout: 15000 }) + .should(($body) => { + const loggedIn = $body.find('button[title="Log out"]').length > 0; + const onLoginPage = $body.find('input[type="password"]').length > 0 + || $body.find('button').toArray().some((el) => el.textContent.trim() === 'Log In'); + expect(loggedIn || onLoginPage, 'app should show logout button or login form').to.be.true; + }) + .then(($body) => { + if ($body.find('button[title="Log out"]').length > 0) { + cy.get('button[title="Log out"]').click(); + cy.contains('button', 'Log In', { timeout: 15000 }).should('be.visible'); + return; + } + + // Already logged out — just confirm login state + cy.visit('/front/login'); + cy.contains('button', 'Log In', { timeout: 15000 }).should('be.visible'); + }); + }); + + Cypress.Commands.add('loginAdminInterface', () => { + cy.visit('/api/admin'); + cy.fixture('cred').then((cred) => { + cy.get('input[type="text"]').type(cred.username); + cy.get('input[type="password"]').type(cred.password); + cy.get('input[type="submit"]').click(); + cy.contains('Site administration').should('be.visible'); + }); + }); + + Cypress.Commands.add('logoutAdminInterface', () => { + cy.visit('/api/admin'); + cy.get('body', { timeout: 15000 }).then(($body) => { + if ($body.find('button:contains("Log out"), a:contains("Log out")').length > 0) { + cy.contains('button, a', 'Log out').click(); + } + }); + }); +} diff --git a/cypress/support/commands/form.commands.js b/cypress/support/commands/form.commands.js new file mode 100644 index 0000000..693341f --- /dev/null +++ b/cypress/support/commands/form.commands.js @@ -0,0 +1,107 @@ +// Generic form-action helpers shared across the suite. +// +// These commands replace the hand-rolled save/progressbar/journal patterns +// that appeared in payment-plan.commands.js, programs.commands.js, etc. + +import { TIMEOUTS } from '../constants'; + +// The "Save" FAB uses one of three known tooltip titles depending on the +// form and its state. Listed most-specific first. +const KNOWN_SAVE_TITLES = [ + 'Save changes', + 'Save', + 'Please fill General Information fields first', +]; + +export function registerFormCommands() { + // Wait for the journal-progress indicator to appear and then disappear. + // This is the "async mutation finished" signal used throughout the app. + Cypress.Commands.add('waitForJournalProgress', () => { + cy.get('ul.MuiList-root li div[role="progressbar"]', { timeout: TIMEOUTS.BACKEND_VALIDATION }) + .should('exist'); + cy.get('ul.MuiList-root li div[role="progressbar"]').should('not.exist'); + }); + + // Open the journal drawer (if not already open) and assert its first entry. + Cypress.Commands.add('assertJournalFirstEntryContains', (text) => { + cy.get('.MuiDrawer-paperAnchorRight button').first().click(); + cy.get('ul.MuiList-root li').first().should('contain', text); + }); + + // Assert that the first journal entry does NOT contain the given prefix. + // Typical use: assertJournalNoFail('Failed to create'). + Cypress.Commands.add('assertJournalNoFail', (prefix = 'Failed to') => { + cy.get('ul.MuiList-root li').first().should('not.contain', prefix); + }); + + Cypress.Commands.add('createClick', (createTitle = 'Create') => { + cy.get(`[title="${createTitle}"] button`, { timeout: TIMEOUTS.BACKEND_VALIDATION }) + .should('not.be.disabled') + .click(); + }); + + // Click the Save FAB. Tries each known tooltip title (most-specific + // first) and clicks the first non-disabled match. Pass an explicit + // title string to scope to a single candidate. + Cypress.Commands.add('saveClick', (saveTitle) => { + const titles = saveTitle ? [saveTitle] : KNOWN_SAVE_TITLES; + cy.get('body', { timeout: TIMEOUTS.BACKEND_VALIDATION }).should(($body) => { + const hit = titles.find((t) => $body.find(`[title="${t}"] button`).length); + expect( + hit, + `saveClick: no Save FAB found with any known title (${titles.join(', ')})`, + ).to.exist; + }).then(($body) => { + const hit = titles.find((t) => $body.find(`[title="${t}"] button`).length); + cy.get(`[title="${hit}"] button`, { timeout: TIMEOUTS.BACKEND_VALIDATION }) + .should('not.be.disabled') + .click(); + }); + }); + + // Click the Save FAB and wait for the async journal to finish. + // + // Options: + // saveTitle — exact `[title="..."]` of the Save FAB. When omitted + // the helper tries KNOWN_SAVE_TITLES in order. + // mutationLabel — if set, asserts the first journal entry contains this text + // assertNoFail — if set, asserts the first journal entry doesn't start with 'Failed to' + Cypress.Commands.add('saveAndAwaitJournal', ({ + saveTitle, + mutationLabel, + assertNoFail = false, + } = {}) => { + cy.saveClick(saveTitle); + cy.waitForJournalProgress(); + if (mutationLabel || assertNoFail) { + cy.assertJournalFirstEntryContains(mutationLabel || ''); + if (assertNoFail) { + cy.assertJournalNoFail('Failed to'); + } + } + }); + + // Assert the Save FAB. Tries each known tooltip title; passes + // if any of them matches. Optionally scope to a specific title. + Cypress.Commands.add('assertSave', (enabled = true, tooltipText) => { + const titles = tooltipText ? [tooltipText] : KNOWN_SAVE_TITLES; + cy.get('body').then(($body) => { + const hit = titles.find((t) => $body.find(`[title="${t}"] button`).length); + if (!hit) { + throw new Error( + `assertSave: no Save FAB found with any known title (${titles.join(', ')})`, + ); + } + const assertion = enabled ? 'not.be.disabled' : 'be.disabled'; + cy.get(`[title="${hit}"] button`).should(assertion); + }); + }); + + Cypress.Commands.add('assertSaveDisabled', (tooltipText) => { + cy.assertSave(false, tooltipText); + }); + + Cypress.Commands.add('assertSaveEnabled', (tooltipText) => { + cy.assertSave(true, tooltipText); + }); +} diff --git a/cypress/support/commands/grievance.commands.js b/cypress/support/commands/grievance.commands.js new file mode 100644 index 0000000..889bf4b --- /dev/null +++ b/cypress/support/commands/grievance.commands.js @@ -0,0 +1,296 @@ +export function registerGrievanceCommands() { + Cypress.Commands.add('getGrievanceCount', () => { + const pattern = /\(\d+\) Grievance\(s\)/; + return cy.contains(pattern) + .invoke('text') + .then((text) => { + const match = text.match(/\((\d+)\) Grievance/); + return parseInt(match?.[1], 10); + }); + }); + + Cypress.Commands.add('createGrievance', (grievanceData) => { + cy.visit('/front/ticket/newTicket'); + + // Required fields + cy.enterMuiInput('Grievance Title', grievanceData.title); + cy.chooseMuiAutocomplete('Category', grievanceData.category); + cy.chooseMuiAutocomplete('Flag', grievanceData.flag); + cy.chooseMuiAutocomplete('Channel', grievanceData.channel); + + // Optional fields + if (grievanceData.priority) { + cy.chooseMuiSelect('Priority', grievanceData.priority); + } + + if (grievanceData.dateOfIncident) { + cy.contains('label', 'Date Of Incident') + .parent() + .find('input') + .type(grievanceData.dateOfIncident); + } + + if (grievanceData.assignedUser) { + cy.chooseMuiAutocomplete('Assigned User', grievanceData.assignedUser); + } + + if (grievanceData.details) { + cy.enterMuiInput('DETAILS OF EVENT', grievanceData.details); + } + + // Reporter type handling + if (grievanceData.reporterType) { + cy.chooseMuiSelect('Reporter Type', grievanceData.reporterType); + + if (grievanceData.reporterType === 'Individual') { + if (grievanceData.benefitPlan) { + cy.chooseMuiAutocomplete('Program', grievanceData.benefitPlan); + } + if (grievanceData.individual) { + cy.chooseMuiAutocomplete('Individual', grievanceData.individual); + } else { + cy.chooseMuiAutocomplete('Individual'); + } + } else if (grievanceData.reporterType === 'Beneficiary') { + if (grievanceData.benefitPlan) { + cy.chooseMuiAutocomplete('Program', grievanceData.benefitPlan); + } + if (grievanceData.beneficiary) { + cy.chooseMuiAutocomplete('BeneficiaryPicker', grievanceData.beneficiary); + } else { + cy.chooseMuiAutocomplete('BeneficiaryPicker'); + } + } else if (grievanceData.reporterType === 'Attending Staff') { + if (grievanceData.attendingStaff) { + cy.chooseMuiAutocomplete('Complainant', grievanceData.attendingStaff); + } else { + cy.chooseMuiAutocomplete('Complainant'); + } + } + } + + // Save the grievance + cy.get('label[role="button"].MuiIconButton-colorPrimary').click(); + + // Wait for creation to complete + cy.get('ul.MuiList-root li div[role="progressbar"]', { timeout: 15000 }).should('exist'); + cy.get('ul.MuiList-root li div[role="progressbar"]', { timeout: 15000 }).should('not.exist'); + + // Check journal for success + cy.get('ul.MuiList-root li').first().click(); + cy.contains(`Created Ticket ${grievanceData.title}`).should('exist'); + cy.contains('Failed to create').should('not.exist'); + }); + + Cypress.Commands.add('updateGrievance', (grievanceCode, updateData, immutableFields = {}) => { + cy.visit('/front/ticket/tickets'); + cy.contains('tfoot', 'Rows Per Page').should('be.visible'); + + // Search for grievance by code + cy.enterMuiInput('Code', grievanceCode); + cy.contains('button', 'Search').click(); + cy.contains('tfoot', 'Rows Per Page').should('be.visible'); + + // Open grievance for edit + cy.contains('td', grievanceCode) + .parent('tr') + .within(() => { + cy.get('button[title="Edit"]').click(); + }); + + if (immutableFields.reporterType) { + cy.assertMuiInputDisabled('Reporter Type', immutableFields.reporterType); + } + + if (immutableFields.reporterFieldLabel) { + cy.assertMuiInputDisabled( + immutableFields.reporterFieldLabel, + immutableFields.reporterFieldValue ?? null, + ); + } + + // Update fields (excluding reporter type and reporter info) + if (updateData.title) { + cy.enterMuiInput('Title', updateData.title); + } + + if (updateData.category) { + cy.chooseMuiAutocomplete('Category', updateData.category); + } + + if (updateData.flag) { + cy.chooseMuiAutocomplete('Flag', updateData.flag); + } + + if (updateData.channel) { + cy.chooseMuiAutocomplete('Channel', updateData.channel); + } + + if (updateData.priority) { + cy.chooseMuiSelect('Priority', updateData.priority); + } + + if (updateData.details) { + cy.enterMuiInput('Description', updateData.details); + } + + // Save changes + cy.get('label[role="button"].MuiIconButton-colorPrimary').click(); + + // Wait for update to complete + cy.get('ul.MuiList-root li div[role="progressbar"]', { timeout: 15000 }).should('exist'); + cy.get('ul.MuiList-root li div[role="progressbar"]', { timeout: 15000 }).should('not.exist'); + + // Check journal for success + cy.get('ul.MuiList-root li').first().click(); + cy.contains('updated ticket', { timeout: 10000 }).should('exist'); + cy.contains('Failed to update').should('not.exist'); + }); + + Cypress.Commands.add('resolveGrievance', (grievanceCode, comment = 'Resolved Grievance') => { + cy.searchAndOpenGrievanceForEdit(grievanceCode); + cy.addGrievanceComment(comment); + + // Click the tick mark icon on the first/latest comment to resolve + cy.get('button[title="Resolve grievance with this comment."]').first().click(); + + // Wait for resolve to complete + cy.get('ul.MuiList-root li div[role="progressbar"]', { timeout: 15000 }).should('exist'); + cy.get('ul.MuiList-root li div[role="progressbar"]', { timeout: 15000 }).should('not.exist'); + + // Check journal for success + cy.get('ul.MuiList-root li').first().click(); + cy.contains('Resolve Ticket using comment', { timeout: 10000 }).should('exist'); + cy.contains('Failed').should('not.exist'); + }); + + Cypress.Commands.add('unlockGrievance', (grievanceCode) => { + cy.searchAndOpenGrievanceForEdit(grievanceCode); + + // Click the unlock icon (lock icon in header action area) + cy.get('div[class*="paperHeaderAction"] button.MuiIconButton-root').click(); + + cy.get('ul.MuiList-root li div[role="progressbar"]', { timeout: 15000 }).should('exist'); + cy.get('ul.MuiList-root li div[role="progressbar"]', { timeout: 15000 }).should('not.exist'); + + cy.get('ul.MuiList-root li').first().click(); + cy.contains('Failed').should('not.exist'); + }); + + Cypress.Commands.add('checkGrievanceFieldValues', (title, category, flag, channel, priority = null, details = null) => { + cy.assertMuiInput('Grievance Title', title); + cy.assertMuiInput('Category', category); + cy.assertMuiInput('Flag', flag); + cy.assertMuiInput('Channel', channel); + if (priority) { + cy.assertMuiSelectValue('Priority', priority); + } + + if (details) { + cy.assertMuiInput('Description', details); + } + }); + + Cypress.Commands.add('checkGrievanceFieldValuesInListView', (title, category) => { + cy.contains('tfoot', 'Rows Per Page').should('be.visible'); + + cy.enterMuiInput('Title', title); + cy.contains('button', 'Search').click(); + cy.contains('tfoot', 'Rows Per Page').should('be.visible'); + + cy.contains('td', title).should('exist'); + cy.contains('td', title) + .parent('tr') + .within(() => { + cy.contains('td', category).should('exist'); + }); + }); + + Cypress.Commands.add('getGrievanceCodeFromList', (title) => { + cy.visit('/front/ticket/tickets'); + cy.contains('tfoot', 'Rows Per Page').should('be.visible'); + + cy.enterMuiInput('Title', title); + cy.contains('button', 'Search').click(); + cy.contains('tfoot', 'Rows Per Page').should('be.visible'); + + return cy.contains('td', title) + .parent('tr') + .find('td') + .first() + .invoke('text') + .then((code) => code.trim()); + }); + + Cypress.Commands.add('searchAndOpenGrievanceForEdit', (grievanceCode) => { + cy.visit('/front/ticket/tickets'); + cy.contains('tfoot', 'Rows Per Page').should('be.visible'); + + cy.enterMuiInput('Code', grievanceCode); + cy.contains('button', 'Search').click(); + cy.contains('tfoot', 'Rows Per Page').should('be.visible'); + + cy.contains('td', grievanceCode) + .parent('tr') + .within(() => { + cy.get('button[title="Edit"]').click(); + }); + }); + + Cypress.Commands.add('addGrievanceComment', (commentText, commentData = {}) => { + cy.contains('button', 'Add Comment').click(); + // Use native HTMLInputElement value setter to avoid DOM detachment caused by + // React's per-keystroke re-renders in TicketCommentsPanel (setInterval + controlled input). + cy.contains('label', 'Comment') + .siblings('.MuiInputBase-root') + .find('input') + .first() + .then(($input) => { + const inputEl = $input[0]; + const win = inputEl.ownerDocument.defaultView || window; + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + win.HTMLInputElement.prototype, 'value', + ).set; + nativeInputValueSetter.call(inputEl, commentText); + inputEl.dispatchEvent(new Event('input', { bubbles: true })); + }); + + if (commentData.reporterType) { + cy.chooseMuiSelect('Reporter Type', commentData.reporterType); + + if (commentData.reporterType === 'Individual') { + if (commentData.benefitPlan) { + cy.chooseMuiAutocomplete('Program', commentData.benefitPlan); + } + if (commentData.individual) { + cy.chooseMuiAutocomplete('Individual', commentData.individual); + } else { + cy.chooseMuiAutocomplete('Individual'); + } + } else if (commentData.reporterType === 'Beneficiary') { + if (commentData.benefitPlan) { + cy.chooseMuiAutocomplete('Program', commentData.benefitPlan); + } + if (commentData.beneficiary) { + cy.chooseMuiAutocomplete('Beneficiary', commentData.beneficiary); + } else { + cy.chooseMuiAutocomplete('Beneficiary'); + } + } else if (commentData.reporterType === 'Attending Staff') { + if (commentData.attendingStaff) { + cy.chooseMuiAutocomplete('Commenter', commentData.attendingStaff); + } else { + cy.chooseMuiAutocomplete('Commenter'); + } + } + } + + cy.contains('button', 'Save').click(); + + // Wait for save mutation to complete before reloading + cy.get('ul.MuiList-root li div[role="progressbar"]', { timeout: 15000 }).should('exist'); + cy.get('ul.MuiList-root li div[role="progressbar"]', { timeout: 15000 }).should('not.exist'); + + cy.contains(commentText).should('exist'); + }); +} diff --git a/cypress/support/commands/index.js b/cypress/support/commands/index.js new file mode 100644 index 0000000..43b24d9 --- /dev/null +++ b/cypress/support/commands/index.js @@ -0,0 +1,23 @@ +import { registerUiCommands } from './ui.commands'; +import { registerAuthCommands } from './auth.commands'; +import { registerAdminCommands } from './admin.commands'; +import { registerFormCommands } from './form.commands'; +import { registerSearcherCommands } from './searcher.commands'; +import { registerLocationCommands } from './location.commands'; +import { registerRegistryCommands } from './registry.commands'; +import { registerTaskCommands } from './tasks.commands'; +import { registerProgramCommands } from './programs.commands'; +import { registerGrievanceCommands } from './grievance.commands'; +import { registerPaymentCommands } from './payments'; + +registerUiCommands(); +registerAuthCommands(); +registerAdminCommands(); +registerFormCommands(); +registerSearcherCommands(); +registerLocationCommands(); +registerRegistryCommands(); +registerTaskCommands(); +registerProgramCommands(); +registerGrievanceCommands(); +registerPaymentCommands(); diff --git a/cypress/support/commands/location.commands.js b/cypress/support/commands/location.commands.js new file mode 100644 index 0000000..263ecb3 --- /dev/null +++ b/cypress/support/commands/location.commands.js @@ -0,0 +1,32 @@ +// Generic location-picker helpers. +// +// In the openIMIS UI, Region and District pickers are rendered as +// AutoSuggestion/MUI Select, while Municipality and Village are +// LocationPicker/MUI Autocomplete. This module hides that asymmetry behind +// a single `chooseLocation` command. + +const LOCATION_SELECT_LABELS = ['Region', 'District']; + +function chooseLocationLevel(label, value) { + if (LOCATION_SELECT_LABELS.includes(label)) { + cy.chooseMuiSelect(label, value); + } else { + cy.chooseMuiAutocomplete(label, value); + } +} + +export function registerLocationCommands() { + // Pick values top-down across the location hierarchy. Each level is + // optional; the user passes only the levels they want to set. + Cypress.Commands.add('chooseLocation', ({ + region, district, municipality, village, + } = {}) => { + if (region) chooseLocationLevel('Region', region); + if (district) chooseLocationLevel('District', district); + if (municipality) chooseLocationLevel('Municipality', municipality); + if (village) chooseLocationLevel('Village', village); + }); + + // Exposed for callers that only need to pick a single level (e.g. filter panes). + Cypress.Commands.add('chooseLocationLevel', chooseLocationLevel); +} diff --git a/cypress/support/commands/payments/index.js b/cypress/support/commands/payments/index.js new file mode 100644 index 0000000..68ee9cf --- /dev/null +++ b/cypress/support/commands/payments/index.js @@ -0,0 +1,11 @@ +import { registerPaymentPlanCommands } from './payment-plan.commands'; +import { registerPaymentCycleCommands } from './payment-cycle.commands'; +import { registerPayrollCommands } from './payroll.commands'; +import { registerPaymentPointCommands } from './payment-point.commands'; + +export function registerPaymentCommands() { + registerPaymentPlanCommands(); + registerPaymentCycleCommands(); + registerPayrollCommands(); + registerPaymentPointCommands(); +} diff --git a/cypress/support/commands/payments/payment-cycle.commands.js b/cypress/support/commands/payments/payment-cycle.commands.js new file mode 100644 index 0000000..fb7cf1b --- /dev/null +++ b/cypress/support/commands/payments/payment-cycle.commands.js @@ -0,0 +1,151 @@ +import { TIMEOUTS } from '../../constants'; + +export function registerPaymentCycleCommands() { + Cypress.Commands.add('assertPaymentCycleDetailFields', ({ + code, + startDate, + status, + endDate, + }) => { + cy.assertMuiInput('Code', code); + cy.assertMuiInput('Start Date', startDate); + cy.assertMuiInput('End Date', endDate); + cy.assertMuiSelectValue('Status', status); + }); + + Cypress.Commands.add('openCreatePaymentCycle', () => { + cy.visit('/front/paymentCycles'); + cy.contains('Payment Cycles Found'); + + cy.createClick(); + cy.contains('General Information'); + }); + + Cypress.Commands.add('fillPaymentCycleForm', ({ + code, + startDate, + endDate, + status, + }) => { + if (code !== undefined) { + cy.enterMuiInput('Code', code); + } + if (startDate) { + cy.enterDateInput('Start Date', startDate); + } + if (endDate) { + cy.enterDateInput('End Date', endDate); + } + if (status) { + cy.chooseMuiSelect('Status', status); + } + }); + + // savePaymentCycle branches on the form's current Status to choose the + // expected post-save UX: + // + // 1. Direct creation (PENDING, SUSPENDED): the server redirects to + // /paymentCycle/{UUID}. + // + // 2. Task workflow (ACTIVE): a + // notification dialog appears which must be dismissed. The caller is + // responsible for approving the resulting task via + // `cy.approveLatestPaymentCycleTask()` before the cycle becomes ACTIVE. + // + // Reading the Status select up front keeps this helper deterministic and + // avoids fragile DOM race conditions right after the save click. + Cypress.Commands.add('savePaymentCycle', () => { + cy.contains('label', 'Status') + .siblings('.MuiInputBase-root') + .find('[role="button"]') + .invoke('text') + .then((statusText) => { + const expectDialog = statusText.trim().toUpperCase() === 'ACTIVE'; + cy.log(`savePaymentCycle: status="${statusText.trim()}" → expectDialog=${expectDialog}`); + + cy.saveClick(); + + if (expectDialog) { + cy.get('[role="dialog"]', { timeout: TIMEOUTS.BACKEND_VALIDATION }) + .should('be.visible'); + cy.get('[role="dialog"] .MuiDialogActions-root button').first().click(); + cy.url().should('include', '/paymentCycles'); + } else { + cy.url({ timeout: TIMEOUTS.BACKEND_VALIDATION }) + .should('match', /\/paymentCycle\/[^/]+/); + } + }); + }); + + Cypress.Commands.add('createPaymentCycle', ({ + code, + startDate, + endDate, + status = 'PENDING', + }) => { + cy.openCreatePaymentCycle(); + cy.fillPaymentCycleForm({ + code, + startDate, + endDate, + status, + }); + cy.savePaymentCycle(); + }); + + Cypress.Commands.add('filterPaymentCycles', ({ + code, + status, + dateFrom, + dateTo, + } = {}) => { + cy.visit('/front/paymentCycles'); + cy.contains(/\d+ Payment Cycle/, { timeout: TIMEOUTS.BACKEND_VALIDATION }); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(1000); + + if (code !== undefined) { + cy.enterMuiInput('Code', code); + } + if (status !== undefined) { + cy.chooseMuiSelect('Status', status); + } + // Searcher filter labels are "Date From" / "Date To" (translation + // `paymentCycle.label.dateValidFrom` / `dateValidTo`) — distinct from + // the create-form labels "Start Date" / "End Date". + if (dateFrom) { + cy.enterDateInput('Date From', dateFrom); + } + if (dateTo) { + cy.enterDateInput('Date To', dateTo); + } + + cy.aliasGraphqlQuery('paymentCycle(', 'paymentCycleSearch'); + cy.contains('button', 'Search').click(); + cy.awaitSearcherRefresh('paymentCycleSearch', /\d+ Payment Cycle/); + }); + + Cypress.Commands.add('assertPaymentCycleRowVisible', ({ code, status }) => { + cy.assertTableRowVisible([code, status]); + }); + + Cypress.Commands.add('assertPaymentCycleRowNotVisible', ({ code }) => { + cy.assertTableRowNotVisible([code]); + }); + + Cypress.Commands.add('resetPaymentCycleFilters', () => { + cy.aliasGraphqlQuery('paymentCycle(', 'paymentCycleReset'); + cy.resetSearcherFilters(/\d+ Payment Cycle/, 'paymentCycleReset'); + }); + + Cypress.Commands.add('openPaymentCycleForViewFromList', (code) => { + cy.filterPaymentCycles({ code }); + // Row has a single action: View Details (Eye icon). + cy.contains('table tbody tr', code) + .should('exist') + .within(() => { + cy.get('button.MuiIconButton-root').click({ force: true }); + }); + cy.contains('General Information'); + }); +} diff --git a/cypress/support/commands/payments/payment-plan.commands.js b/cypress/support/commands/payments/payment-plan.commands.js new file mode 100644 index 0000000..025be50 --- /dev/null +++ b/cypress/support/commands/payments/payment-plan.commands.js @@ -0,0 +1,278 @@ +import { TIMEOUTS } from '../../constants'; + +// Fill the calculation-params fields that appear after a calcrule is picked. +// The fields render asynchronously once the backend finishes validating the +// rule, so each label is awaited before typing. +function fillCalculationParams(params) { + Object.entries(params).forEach(([label, value]) => { + cy.contains('label', label, { timeout: TIMEOUTS.BACKEND_VALIDATION }).should('be.visible'); + cy.enterMuiInput(label, String(value)); + }); +} + +// Fill the criterion row at a given index (0-based). All rows must already +// exist before this is called (i.e. "Add criterion" was clicked once per row). +// Using .eq(index) rather than .last() is stable when multiple rows are +// present: rows are filled in order 0, 1, … so at the time row i is being +// filled, exactly i rows before it already show their Filter/Value/Amount +// labels, making .eq(i) unambiguous for every label type. +function fillCriterionRow(index, { field, filter, value, amount }) { + // cy.contains('label', text) returns a SINGLE element — .eq(1) on a + // one-element set always fails. Use cy.get().filter() to collect ALL + // matching labels so .eq(index) can address any row. Add a length + // guard so Cypress retries until enough rows are in the DOM. + cy.get('label').filter(':contains("Field")') + .should('have.length.at.least', index + 1) + .eq(index) + .siblings('.MuiInputBase-root') + .find('[role="button"]') + .click(); + cy.contains('[role="listbox"] li', field, { timeout: TIMEOUTS.BACKEND_VALIDATION }).click(); + + cy.get('label').filter(':contains("Confirm Filters")') + .should('have.length.at.least', index + 1) + .eq(index) + .siblings('.MuiInputBase-root') + .find('[role="button"]') + .click(); + cy.contains('[role="listbox"] li', filter).click(); + + cy.get('label').filter(':contains("Value")') + .should('have.length.at.least', index + 1) + .eq(index) + .siblings('.MuiInputBase-root') + .find('input') + .first() + .clear({ force: true }) + .type(String(value), { force: true }); + + if (amount !== undefined && amount !== null) { + cy.get('label').filter(':contains("Amount")') + .should('have.length.at.least', index + 1) + .eq(index) + .siblings('.MuiInputBase-root') + .find('input') + .first() + .clear({ force: true }) + .type(String(amount), { force: true }); + } +} + +export function registerPaymentPlanCommands() { + Cypress.Commands.add('assertPaymentPlanDetailFields', ({ + code, + name, + dateValidFrom, + dateValidTo, + calculationRule, + benefitPlanCode, + benefitPlanName, + calculationParams, + }) => { + cy.contains('General Information').should('be.visible'); + cy.assertMuiInput('Code', code); + cy.assertMuiInput('Name', name); + cy.assertMuiInput('Valid from', dateValidFrom); + if (dateValidTo) { + cy.assertMuiInput('Valid to', dateValidTo); + } + if (calculationRule) { + cy.assertMuiSelectValue('Calculation Rule', calculationRule); + } + // Prefer code over name for the Program picker assertion: the picker's + // displayed value is `${code} ${name}`, and the program name field is + // prone to typing races during create. The 8-char code is a reliable + // identity check. + if (benefitPlanCode) { + cy.assertMuiAutoComplete('Program', benefitPlanCode); + } else if (benefitPlanName) { + cy.assertMuiAutoComplete('Program', benefitPlanName); + } + if (calculationParams) { + Object.entries(calculationParams).forEach(([label, value]) => { + cy.assertMuiInput(label, String(value)); + }); + } + }); + + Cypress.Commands.add('openCreatePaymentPlan', () => { + cy.visit('/front/paymentPlans'); + cy.contains('Payment Plans Found'); + + cy.createClick('Create new Payment Plan'); + cy.contains('General Information'); + }); + + Cypress.Commands.add('fillPaymentPlanForm', ({ + type, + code, + name, + calculationRule, + calculationParams, + benefitPlanCode, + benefitPlanName, + dateValidFrom, + dateValidTo, + advancedCriteria, + }) => { + if (type) { + cy.chooseMuiSelect('Type', type); + } + if (code !== undefined) { + cy.enterMuiInput('Code', code); + } + if (name !== undefined) { + cy.enterMuiInput('Name', name); + } + // undefined → select first available; null → skip (e.g. duplicate-code test); string → select that rule + if (calculationRule !== null) { + if (calculationRule) { + cy.chooseMuiSelect('Calculation Rule', calculationRule); + } else { + cy.chooseFirstMuiSelect('Calculation Rule'); + } + } + // Prefer searching by program code: the 8-char unique code is reliably + // matched by the BenefitPlanPicker server-side OR-search and avoids + // ambiguity if the program name was mangled during program creation. + if (benefitPlanCode) { + cy.chooseMuiAutocomplete('Program', benefitPlanCode); + } else if (benefitPlanName) { + cy.chooseMuiAutocomplete('Program', benefitPlanName); + } + if (dateValidFrom) { + cy.enterDateInput('Valid from', dateValidFrom); + } + if (dateValidTo) { + cy.enterDateInput('Valid to', dateValidTo); + } + if (calculationParams) { + fillCalculationParams(calculationParams); + } + if (advancedCriteria) { + const criteriaList = Array.isArray(advancedCriteria) ? advancedCriteria : [advancedCriteria]; + // Add ALL rows first, then fill each one by stable index position. + criteriaList.forEach(() => cy.contains('button', 'Add criterion').click()); + criteriaList.forEach((criterion, index) => fillCriterionRow(index, criterion)); + cy.contains('button', 'Confirm Criteria').click(); + } + }); + + Cypress.Commands.add('savePaymentPlan', (mutationLabel = null) => { + cy.saveAndAwaitJournal({ saveTitle: 'Save changes', mutationLabel }); + }); + + Cypress.Commands.add('createPaymentPlan', ({ + type = 'Benefit Plan', + code, + name, + calculationRule, + calculationParams, + benefitPlanCode, + benefitPlanName, + dateValidFrom, + dateValidTo, + advancedCriteria, + }) => { + cy.openCreatePaymentPlan(); + cy.fillPaymentPlanForm({ + type, + code, + name, + calculationRule, + calculationParams, + benefitPlanCode, + benefitPlanName, + dateValidFrom, + dateValidTo, + advancedCriteria, + }); + cy.savePaymentPlan('Create Payment Plan'); + cy.assertJournalNoFail('Failed to create'); + }); + + Cypress.Commands.add('filterPaymentPlans', ({ + code, + name, + dateValidFrom, + dateValidTo, + showDeleted = false, + showHistory = false, + } = {}) => { + // (limitation) No filter for benefit plan — the UI doesn't expose one. + cy.visit('/front/paymentPlans'); + cy.contains('Payment Plans Found'); + + if (code !== undefined) { + cy.enterMuiInput('Code', code); + } + if (name !== undefined) { + cy.enterMuiInput('Name', name); + } + if (dateValidFrom) { + cy.enterDateInput('Valid from', dateValidFrom); + } + if (dateValidTo) { + cy.enterDateInput('Valid to', dateValidTo); + } + if (showDeleted) { + cy.toggleMuiCheckbox('isDeleted', true); + } + if (showHistory) { + cy.toggleMuiCheckbox('showHistory', true); + } + + cy.aliasGraphqlQuery('paymentPlan(', 'paymentPlanSearch'); + cy.contains('button', 'Search').click(); + cy.awaitSearcherRefresh('paymentPlanSearch', /Payment Plans Found/); + }); + + Cypress.Commands.add('resetPaymentPlanFilters', () => { + cy.aliasGraphqlQuery('paymentPlan(', 'paymentPlanReset'); + cy.resetSearcherFilters(/Payment Plans Found/, 'paymentPlanReset'); + }); + + Cypress.Commands.add('assertPaymentPlanRowVisible', ({ code, name }) => { + cy.assertTableRowVisible([code, name]); + }); + + Cypress.Commands.add('assertPaymentPlanRowNotVisible', ({ code, name }) => { + cy.assertTableRowNotVisible([code, name]); + }); + + Cypress.Commands.add('openPaymentPlanForEditFromList', (nameOrCode) => { + cy.filterPaymentPlans({ code: nameOrCode }); + // Edit is rendered as an (IconButton with href), not a