diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index 60ea1c698..a252f09c7 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -25,8 +25,7 @@ jobs: - 6379:6379 rabbitmq: image: rabbitmq:3-management - container_name: rabbitmq - environment: + env: RABBITMQ_DEFAULT_USER: lcfs RABBITMQ_DEFAULT_PASS: development_only RABBITMQ_DEFAULT_VHOST: lcfs @@ -34,28 +33,49 @@ jobs: - "15672:15672" steps: - uses: actions/checkout@v2 + - name: Build and Run Backend Service run: | - docker build -t backend-service ./backend/Dockerfile - docker run -d --name backend -e LCFS_DB_HOST=localhost -e LCFS_REDIS_HOST=localhost -p 8000:8000 backend-service + docker build -t backend-service -f ./backend/Dockerfile ./backend + docker run -d --name backend \ + --network=host \ + -e LCFS_DB_HOST=localhost \ + -e LCFS_REDIS_HOST=localhost \ + backend-service + + - name: Wait for DB to be Ready + run: sleep 20 + + - name: Run Alembic Migrations + run: docker exec backend poetry run alembic upgrade head + - name: Data Seeding run: docker exec backend poetry run python /app/lcfs/db/seeders/seed_database.py + - name: Build and Run Frontend Service run: | - docker build -t frontend-service ./fontend/Dockerfile.dev - docker run -d --name frontend -p 3000:3000 frontend-service + docker build -t frontend-service -f ./frontend/Dockerfile.dev ./frontend + docker run -d --name frontend \ + --network=host \ + frontend-service + - name: Cypress run uses: cypress-io/github-action@v2 with: browser: chrome wait-on: 'http://localhost:3000' wait-on-timeout: 60 + record: false + config-file: cypress.config.js + working-directory: frontend env: IDIR_TEST_USER: ${{ secrets.CYPRESS_IDIR_TEST_USER }} IDIR_TEST_PASS: ${{ secrets.CYPRESS_IDIR_TEST_PASS }} BCEID_TEST_USER: ${{ secrets.CYPRESS_BCEID_TEST_USER }} BCEID_TEST_PASS: ${{ secrets.CYPRESS_BCEID_TEST_PASS }} + - name: Cleanup + if: always() run: | docker stop backend frontend - docker rm backend frontend \ No newline at end of file + docker rm backend frontend diff --git a/backend/lcfs/db/migrations/versions/2025-01-13-22-13_f78e53370ed2.py b/backend/lcfs/db/migrations/versions/2025-01-13-22-13_f78e53370ed2.py new file mode 100644 index 000000000..f97aa17c6 --- /dev/null +++ b/backend/lcfs/db/migrations/versions/2025-01-13-22-13_f78e53370ed2.py @@ -0,0 +1,238 @@ +"""Add CR to Transaction Aggregate + +Revision ID: f78e53370ed2 +Revises: d25e7c47659e +Create Date: 2025-01-13 22:13:48.610890 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "f78e53370ed2" +down_revision = "d25e7c47659e" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute("DROP MATERIALIZED VIEW mv_transaction_aggregate;") + op.execute( + """ + CREATE MATERIALIZED VIEW mv_transaction_aggregate AS + SELECT + t.transfer_id AS transaction_id, + 'Transfer' AS transaction_type, + NULL AS description, + org_from.organization_id AS from_organization_id, + org_from.name AS from_organization, + org_to.organization_id AS to_organization_id, + org_to.name AS to_organization, + t.quantity, + t.price_per_unit, + ts.status::text AS status, + NULL AS compliance_period, + t.from_org_comment AS COMMENT, + tc.category, + ( + SELECT + th.create_date + FROM + transfer_history th + WHERE + th.transfer_id = t.transfer_id + AND th.transfer_status_id = 6) AS recorded_date, NULL AS approved_date, t.transaction_effective_date, t.update_date, t.create_date + FROM + transfer t + JOIN organization org_from ON t.from_organization_id = org_from.organization_id + JOIN organization org_to ON t.to_organization_id = org_to.organization_id + JOIN transfer_status ts ON t.current_status_id = ts.transfer_status_id + LEFT JOIN transfer_category tc ON t.transfer_category_id = tc.transfer_category_id + UNION ALL + SELECT + ia.initiative_agreement_id AS transaction_id, + 'InitiativeAgreement' AS transaction_type, + NULL AS description, + NULL AS from_organization_id, + NULL AS from_organization, + org.organization_id AS to_organization_id, + org.name AS to_organization, + ia.compliance_units AS quantity, + NULL AS price_per_unit, + ias.status::text AS status, + NULL AS compliance_period, + ia.gov_comment AS COMMENT, + NULL AS category, + NULL AS recorded_date, + ( + SELECT + iah.create_date + FROM + initiative_agreement_history iah + WHERE + iah.initiative_agreement_id = ia.initiative_agreement_id + AND iah.initiative_agreement_status_id = 3) AS approved_date, ia.transaction_effective_date, ia.update_date, ia.create_date + FROM + initiative_agreement ia + JOIN organization org ON ia.to_organization_id = org.organization_id + JOIN initiative_agreement_status ias ON ia.current_status_id = ias.initiative_agreement_status_id + UNION ALL + SELECT + aa.admin_adjustment_id AS transaction_id, + 'AdminAdjustment' AS transaction_type, + NULL AS description, + NULL AS from_organization_id, + NULL AS from_organization, + org.organization_id AS to_organization_id, + org.name AS to_organization, + aa.compliance_units AS quantity, + NULL AS price_per_unit, + aas.status::text AS status, + NULL AS compliance_period, + aa.gov_comment AS COMMENT, + NULL AS category, + NULL AS recorded_date, + ( + SELECT + aah.create_date + FROM + admin_adjustment_history aah + WHERE + aah.admin_adjustment_id = aa.admin_adjustment_id + AND aah.admin_adjustment_status_id = 3) AS approved_date, aa.transaction_effective_date, aa.update_date, aa.create_date + FROM + admin_adjustment aa + JOIN organization org ON aa.to_organization_id = org.organization_id + JOIN admin_adjustment_status aas ON aa.current_status_id = aas.admin_adjustment_status_id + UNION ALL + SELECT + cr.compliance_report_id AS transaction_id, + 'ComplianceReport' AS transaction_type, + cr.nickname AS description, + NULL AS from_organization_id, + NULL AS from_organization, + org.organization_id AS to_organization_id, + org.name AS to_organization, + tr.compliance_units AS quantity, + NULL AS price_per_unit, + crs.status::text AS status, + cp.description AS compliance_period, + NULL AS COMMENT, + NULL AS category, + NULL AS recorded_date, + NULL AS approved_date, + NULL AS transaction_effective_date, + cr.update_date, + cr.create_date + FROM + compliance_report cr + JOIN organization org ON cr.organization_id = org.organization_id + JOIN compliance_report_status crs ON cr.current_status_id = crs.compliance_report_status_id + JOIN compliance_period cp ON cr.compliance_period_id = cp.compliance_period_id + JOIN TRANSACTION tr ON cr.transaction_id = tr.transaction_id + AND cr.transaction_id IS NOT NULL; + """ + ) + + # Create unique index on mv_transaction_aggregate + op.execute( + """ + CREATE UNIQUE INDEX mv_transaction_aggregate_unique_idx ON mv_transaction_aggregate (transaction_id, description, transaction_type); + """ + ) + + +def downgrade() -> None: + op.execute("DROP MATERIALIZED VIEW mv_transaction_aggregate;") + op.execute( + """ + CREATE MATERIALIZED VIEW mv_transaction_aggregate AS + SELECT + t.transfer_id AS transaction_id, + 'Transfer' AS transaction_type, + org_from.organization_id AS from_organization_id, + org_from.name AS from_organization, + org_to.organization_id AS to_organization_id, + org_to.name AS to_organization, + t.quantity, + t.price_per_unit, + ts.status::text AS status, + NULL AS compliance_period, + t.from_org_comment AS comment, + tc.category, + ( + SELECT th.create_date + FROM transfer_history th + WHERE th.transfer_id = t.transfer_id AND th.transfer_status_id = 6 + ) AS recorded_date, + NULL AS approved_date, + t.transaction_effective_date, + t.update_date, + t.create_date + FROM transfer t + JOIN organization org_from ON t.from_organization_id = org_from.organization_id + JOIN organization org_to ON t.to_organization_id = org_to.organization_id + JOIN transfer_status ts ON t.current_status_id = ts.transfer_status_id + LEFT JOIN transfer_category tc ON t.transfer_category_id = tc.transfer_category_id + UNION ALL + SELECT + ia.initiative_agreement_id AS transaction_id, + 'InitiativeAgreement' AS transaction_type, + NULL AS from_organization_id, + NULL AS from_organization, + org.organization_id AS to_organization_id, + org.name AS to_organization, + ia.compliance_units AS quantity, + NULL AS price_per_unit, + ias.status::text AS status, + NULL AS compliance_period, + ia.gov_comment AS comment, + NULL AS category, + NULL AS recorded_date, + ( + SELECT iah.create_date + FROM initiative_agreement_history iah + WHERE iah.initiative_agreement_id = ia.initiative_agreement_id AND iah.initiative_agreement_status_id = 3 + ) AS approved_date, + ia.transaction_effective_date, + ia.update_date, + ia.create_date + FROM initiative_agreement ia + JOIN organization org ON ia.to_organization_id = org.organization_id + JOIN initiative_agreement_status ias ON ia.current_status_id = ias.initiative_agreement_status_id + UNION ALL + SELECT + aa.admin_adjustment_id AS transaction_id, + 'AdminAdjustment' AS transaction_type, + NULL AS from_organization_id, + NULL AS from_organization, + org.organization_id AS to_organization_id, + org.name AS to_organization, + aa.compliance_units AS quantity, + NULL AS price_per_unit, + aas.status::text AS status, + NULL AS compliance_period, + aa.gov_comment AS comment, + NULL AS category, + NULL AS recorded_date, + ( + SELECT aah.create_date + FROM admin_adjustment_history aah + WHERE aah.admin_adjustment_id = aa.admin_adjustment_id AND aah.admin_adjustment_status_id = 3 + ) AS approved_date, + aa.transaction_effective_date, + aa.update_date, + aa.create_date + FROM admin_adjustment aa + JOIN organization org ON aa.to_organization_id = org.organization_id + JOIN admin_adjustment_status aas ON aa.current_status_id = aas.admin_adjustment_status_id; + """ + ) + + # Create unique index on mv_transaction_aggregate + op.execute( + """ + CREATE UNIQUE INDEX mv_transaction_aggregate_unique_idx ON mv_transaction_aggregate (transaction_id, transaction_type); + """ + ) diff --git a/backend/lcfs/db/models/transaction/TransactionView.py b/backend/lcfs/db/models/transaction/TransactionView.py index 3f15f7222..8dd11fe6d 100644 --- a/backend/lcfs/db/models/transaction/TransactionView.py +++ b/backend/lcfs/db/models/transaction/TransactionView.py @@ -26,6 +26,7 @@ class TransactionView(BaseModel): # id and type columns are defined as a composite primary key. transaction_id = Column(Integer, primary_key=True) transaction_type = Column(String, primary_key=True) + description = Column(String) from_organization_id = Column(Integer) from_organization = Column(String) to_organization_id = Column(Integer) diff --git a/backend/lcfs/web/api/compliance_report/repo.py b/backend/lcfs/web/api/compliance_report/repo.py index cd45266d3..82b930e97 100644 --- a/backend/lcfs/web/api/compliance_report/repo.py +++ b/backend/lcfs/web/api/compliance_report/repo.py @@ -6,7 +6,7 @@ from fastapi import Depends from sqlalchemy import func, select, and_, asc, desc, update from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import joinedload +from sqlalchemy.orm import joinedload, contains_eager from lcfs.db.dependencies import get_async_db_session from lcfs.db.models.compliance import CompliancePeriod @@ -55,6 +55,27 @@ def __init__( self.fuel_supply_repo = fuel_supply_repo def apply_filters(self, pagination, conditions): + for filter in pagination.filters: + filter_value = filter.filter + + filter_option = filter.type + filter_type = filter.filter_type + if filter.field == "organization": + field = get_field_for_filter(Organization, "name") + conditions.append( + apply_filter_conditions( + field, filter_value, filter_option, filter_type + ) + ) + elif filter.field == "compliance_period": + field = get_field_for_filter(CompliancePeriod, "description") + conditions.append( + apply_filter_conditions( + field, filter_value, filter_option, filter_type + ) + ) + + def apply_sub_filters(self, pagination, conditions): for filter in pagination.filters: filter_value = filter.filter # check if the date string is selected for filter @@ -82,7 +103,7 @@ def apply_filters(self, pagination, conditions): else: filter_value = ComplianceReportStatusEnum(filter_value) elif filter.field == "organization": - field = get_field_for_filter(Organization, "name") + continue elif filter.field == "type": field = get_field_for_filter(ComplianceReport, "reporting_frequency") filter_value = ( @@ -91,7 +112,7 @@ def apply_filters(self, pagination, conditions): else ReportingFrequency.QUARTERLY.value ) elif filter.field == "compliance_period": - field = get_field_for_filter(CompliancePeriod, "description") + continue else: field = get_field_for_filter(ComplianceReport, filter.field) @@ -340,10 +361,12 @@ async def get_reports_paginated( """ # Base query conditions conditions = [] + sub_conditions = [] if organization_id: - conditions.append(ComplianceReport.organization_id == organization_id) + sub_conditions.append(ComplianceReport.organization_id == organization_id) if pagination.filters and len(pagination.filters) > 0: + self.apply_sub_filters(pagination, sub_conditions) self.apply_filters(pagination, conditions) # Pagination and offset setup @@ -356,7 +379,7 @@ async def get_reports_paginated( ComplianceReport.compliance_report_group_uuid, func.max(ComplianceReport.version).label("latest_version"), ) - .where(and_(*conditions)) + .where(and_(*sub_conditions)) .group_by(ComplianceReport.compliance_report_group_uuid) ) @@ -378,9 +401,19 @@ async def get_reports_paginated( ComplianceReport.version == subquery.c.latest_version, ), ) + .join( + Organization, + ComplianceReport.organization_id == Organization.organization_id, + ) + .join( + CompliancePeriod, + ComplianceReport.compliance_period_id + == CompliancePeriod.compliance_period_id, + ) + .where(and_(*conditions)) .options( - joinedload(ComplianceReport.organization), - joinedload(ComplianceReport.compliance_period), + contains_eager(ComplianceReport.organization), + contains_eager(ComplianceReport.compliance_period), joinedload(ComplianceReport.current_status), joinedload(ComplianceReport.summary), joinedload(ComplianceReport.history).joinedload( @@ -404,17 +437,8 @@ async def get_reports_paginated( ) elif order.field == "compliance_period": order.field = get_field_for_filter(CompliancePeriod, "description") - query = query.join( - CompliancePeriod, - ComplianceReport.compliance_period_id - == CompliancePeriod.compliance_period_id, - ) elif order.field == "organization": order.field = get_field_for_filter(Organization, "name") - query = query.join( - Organization, - ComplianceReport.organization_id == Organization.organization_id, - ) else: order.field = get_field_for_filter(ComplianceReport, order.field) query = query.order_by(sort_method(order.field)) diff --git a/backend/lcfs/web/api/transaction/schema.py b/backend/lcfs/web/api/transaction/schema.py index ad0d8411e..1ec597d6a 100644 --- a/backend/lcfs/web/api/transaction/schema.py +++ b/backend/lcfs/web/api/transaction/schema.py @@ -69,10 +69,12 @@ class TransactionBaseSchema(BaseSchema): class TransactionViewSchema(BaseSchema): transaction_id: int transaction_type: str + description: Optional[str] = None from_organization: Optional[str] = None to_organization: str quantity: int price_per_unit: Optional[float] = None + compliance_period: Optional[str] = None status: str create_date: datetime update_date: datetime diff --git a/frontend/src/assets/locales/en/fuelCode.json b/frontend/src/assets/locales/en/fuelCode.json index 701a6231e..2f1fd186a 100644 --- a/frontend/src/assets/locales/en/fuelCode.json +++ b/frontend/src/assets/locales/en/fuelCode.json @@ -23,7 +23,7 @@ "addRow": "Add row", "rows": "rows", "fuelCodeColLabels": { - "fuelCodeId": "Fuel Code Id", + "fuelCodeId": "Fuel Code ID", "status": "Status", "prefix": "Prefix", "fuelSuffix": "Iteration", diff --git a/frontend/src/assets/locales/en/transaction.json b/frontend/src/assets/locales/en/transaction.json index 0f7d7e608..f5fdacf9c 100644 --- a/frontend/src/assets/locales/en/transaction.json +++ b/frontend/src/assets/locales/en/transaction.json @@ -1,7 +1,7 @@ { "title": "Transactions", "txnColLabels": { - "txnId": "Id", + "txnId": "ID", "compliancePeriod": "Compliance period", "type": "Type", "organizationFrom": "From", diff --git a/frontend/src/assets/locales/en/transfer.json b/frontend/src/assets/locales/en/transfer.json index 14ba125bd..47a263a61 100644 --- a/frontend/src/assets/locales/en/transfer.json +++ b/frontend/src/assets/locales/en/transfer.json @@ -81,5 +81,6 @@ "Declined": "Declined", "Rescinded": "Rescinded" }, - "zeroDollarInstructionText": "If proposing a zero-dollar transfer, use the comment box below to provide an explanation for this value." + "zeroDollarInstructionText": "If proposing a zero-dollar transfer, use the comment box below to provide an explanation for this value.", + "categoryCheckbox": "Select the checkbox to set the transfer as Category D if the price is significantly less than fair market value. This will override the default category determined by the agreement and approval dates indicated above." } diff --git a/frontend/src/utils/grid/cellRenderers.jsx b/frontend/src/utils/grid/cellRenderers.jsx index 37917345a..7d210aab0 100644 --- a/frontend/src/utils/grid/cellRenderers.jsx +++ b/frontend/src/utils/grid/cellRenderers.jsx @@ -181,6 +181,7 @@ export const TransactionStatusRenderer = (props) => { 'Sent', 'Submitted', 'Approved', + 'Assessed', 'Recorded', 'Refused', 'Deleted', @@ -194,6 +195,7 @@ export const TransactionStatusRenderer = (props) => { 'info', 'success', 'success', + 'success', 'error', 'error', 'error', diff --git a/frontend/src/views/Transactions/Transactions.jsx b/frontend/src/views/Transactions/Transactions.jsx index 3227b1ab9..d271fdafc 100644 --- a/frontend/src/views/Transactions/Transactions.jsx +++ b/frontend/src/views/Transactions/Transactions.jsx @@ -66,8 +66,13 @@ export const Transactions = () => { url: ( data // Based on the user Type (BCeID or IDIR) navigate to specific view ) => { - const { transactionId, transactionType, fromOrganization, status } = - data.data + const { + transactionId, + transactionType, + fromOrganization, + status, + compliancePeriod + } = data.data const userOrgName = currentUser?.organization?.name // Define routes mapping for transaction types @@ -87,6 +92,10 @@ export const Transactions = () => { ? ROUTES.INITIATIVE_AGREEMENT_VIEW : ROUTES.ORG_INITIATIVE_AGREEMENT_VIEW, edit: ROUTES.INITIATIVE_AGREEMENT_EDIT + }, + ComplianceReport: { + view: ROUTES.REPORTS_VIEW, + edit: ROUTES.INITIATIVE_AGREEMENT_EDIT } } @@ -105,6 +114,8 @@ export const Transactions = () => { return routeTemplate .replace(':transactionId', transactionId) .replace(':transferId', transactionId) + .replace(':compliancePeriod', compliancePeriod) + .replace(':complianceReportId', transactionId) } else { console.error( 'No route defined for this transaction type and scenario' diff --git a/frontend/src/views/Transactions/_schema.js b/frontend/src/views/Transactions/_schema.js index e5e2fded4..969979b87 100644 --- a/frontend/src/views/Transactions/_schema.js +++ b/frontend/src/views/Transactions/_schema.js @@ -14,7 +14,8 @@ import { useTransactionStatuses } from '@/hooks/useTransactions' const prefixMap = { Transfer: 'CT', AdminAdjustment: 'AA', - InitiativeAgreement: 'IA' + InitiativeAgreement: 'IA', + ComplianceReport: 'CR' } export const transactionsColDefs = (t) => [ @@ -36,7 +37,15 @@ export const transactionsColDefs = (t) => [ colId: 'transactionType', field: 'transactionType', headerName: t('txn:txnColLabels.type'), - valueFormatter: spacesFormatter, + valueGetter: (params) => { + const value = spacesFormatter({ value: params.data.transactionType }) + const suffix = params.data.description + + if (suffix) { + return `${value} - ${suffix}` + } + return value + }, filter: true, // Enable filtering filterParams: { textFormatter: (value) => value.replace(/\s+/g, '').toLowerCase(), @@ -56,6 +65,9 @@ export const transactionsColDefs = (t) => [ headerName: t('txn:txnColLabels.organizationFrom'), minWidth: 300, flex: 2, + valueGetter: (params) => { + return params.fromOrganization || 'N/A' + }, filterParams: { buttons: ['clear'] } @@ -91,7 +103,7 @@ export const transactionsColDefs = (t) => [ width: 190, valueGetter: (params) => { const value = params.data?.pricePerUnit - return value !== null && value !== undefined ? value : null + return value !== null && value !== undefined ? value : 'N/A' }, filter: 'agNumberColumnFilter', filterParams: { diff --git a/frontend/src/views/Transfers/AddEditViewTransfer.jsx b/frontend/src/views/Transfers/AddEditViewTransfer.jsx index 4dfb34c09..9dc9b0c54 100644 --- a/frontend/src/views/Transfers/AddEditViewTransfer.jsx +++ b/frontend/src/views/Transfers/AddEditViewTransfer.jsx @@ -441,7 +441,9 @@ export const AddEditViewTransfer = () => { hasAnyRole(roles.analyst) && ( <> - + )} diff --git a/frontend/src/views/Transfers/components/CategoryCheckbox.jsx b/frontend/src/views/Transfers/components/CategoryCheckbox.jsx index 968d04033..a50e73fc8 100644 --- a/frontend/src/views/Transfers/components/CategoryCheckbox.jsx +++ b/frontend/src/views/Transfers/components/CategoryCheckbox.jsx @@ -5,8 +5,10 @@ import { Checkbox, FormControlLabel } from '@mui/material' import { useQueryClient } from '@tanstack/react-query' import { useEffect } from 'react' import { useParams } from 'react-router-dom' +import { useTranslation } from 'react-i18next' -export const CategoryCheckbox = () => { +export const CategoryCheckbox = ({ isDisabled = false }) => { + const { t } = useTranslation(['transfer']) const { transferId } = useParams() const queryClient = useQueryClient() const setLoading = useLoadingStore((state) => state.setLoading) @@ -36,15 +38,14 @@ export const CategoryCheckbox = () => { data-test="checkbox" checked={transferData?.transferCategory?.category === 'D'} onClick={(e) => updateCategory(e.target.checked ? 'D' : null)} + disabled={isDisabled} /> } label={ - - Select the checkbox to set the transfer as{' '} - Category D if the price is significantly less than - fair market value. This will override the default category - determined by the agreement and approval dates indicated above. - + } /> diff --git a/frontend/src/views/Transfers/components/__tests__/CategoryCheckbox.test.jsx b/frontend/src/views/Transfers/components/__tests__/CategoryCheckbox.test.jsx index 236d84f95..f6fc8712f 100644 --- a/frontend/src/views/Transfers/components/__tests__/CategoryCheckbox.test.jsx +++ b/frontend/src/views/Transfers/components/__tests__/CategoryCheckbox.test.jsx @@ -7,7 +7,7 @@ import { useTransfer, useUpdateCategory } from '@/hooks/useTransfer' import { useLoadingStore } from '@/stores/useLoadingStore' const keycloak = vi.hoisted(() => ({ - useKeycloak: vi.fn(), + useKeycloak: vi.fn() })) vi.mock('@react-keycloak/web', () => keycloak) @@ -16,17 +16,17 @@ vi.mock('react-router-dom', async () => { const actual = await vi.importActual('react-router-dom') return { ...actual, - useParams: () => ({ transferId: '123' }), + useParams: () => ({ transferId: '123' }) } }) vi.mock('@/hooks/useTransfer', () => ({ useTransfer: vi.fn(), - useUpdateCategory: vi.fn(), + useUpdateCategory: vi.fn() })) vi.mock('@/stores/useLoadingStore', () => ({ - useLoadingStore: vi.fn(), + useLoadingStore: vi.fn() })) describe('CategoryCheckbox Component', () => { @@ -36,14 +36,17 @@ describe('CategoryCheckbox Component', () => { beforeEach(() => { keycloak.useKeycloak.mockReturnValue({ keycloak: { authenticated: true }, - initialized: true, + initialized: true }) // Correctly mock useLoadingStore to handle the selector - useLoadingStore.mockImplementation((selector) => selector({ setLoading: setLoadingMock })) + useLoadingStore.mockImplementation((selector) => + selector({ setLoading: setLoadingMock }) + ) + // Mock updateCategory hook useUpdateCategory.mockReturnValue({ - mutate: mutateMock, + mutate: mutateMock }) // Reset mocks @@ -58,7 +61,7 @@ describe('CategoryCheckbox Component', () => { it('should render the component', () => { useTransfer.mockReturnValue({ data: {}, - isFetching: false, + isFetching: false }) render(, { wrapper }) @@ -70,7 +73,7 @@ describe('CategoryCheckbox Component', () => { it('should display the checkbox as checked when category is "D"', () => { useTransfer.mockReturnValue({ data: { transferCategory: { category: 'D' } }, - isFetching: false, + isFetching: false }) render(, { wrapper }) @@ -82,7 +85,7 @@ describe('CategoryCheckbox Component', () => { it('should display the checkbox as unchecked when category is not "D"', () => { useTransfer.mockReturnValue({ data: { transferCategory: { category: null } }, - isFetching: false, + isFetching: false }) render(, { wrapper }) @@ -94,7 +97,7 @@ describe('CategoryCheckbox Component', () => { it('should call updateCategory with null when unchecking the checkbox', () => { useTransfer.mockReturnValue({ data: { transferCategory: { category: 'D' } }, - isFetching: false, + isFetching: false }) render(, { wrapper }) @@ -108,7 +111,7 @@ describe('CategoryCheckbox Component', () => { it('should call updateCategory with "D" when checking the checkbox', () => { useTransfer.mockReturnValue({ data: { transferCategory: { category: null } }, - isFetching: false, + isFetching: false }) render(, { wrapper }) @@ -122,11 +125,28 @@ describe('CategoryCheckbox Component', () => { it('should set loading state appropriately during fetch', () => { useTransfer.mockReturnValue({ data: {}, - isFetching: false, + isFetching: false }) render(, { wrapper }) expect(setLoadingMock).toHaveBeenCalledWith(false) }) + + it('should disable the checkbox when isDisabled is true', () => { + useTransfer.mockReturnValue({ + data: { transferCategory: { category: null } }, + isFetching: false + }) + + render(, { wrapper }) + + const checkboxWrapper = screen.getByTestId('checkbox') + const checkboxInput = checkboxWrapper.querySelector( + 'input[type="checkbox"]' + ) + + // Verify the input is indeed disabled + expect(checkboxInput).toBeDisabled() + }) })