blob: e473933dd7974a956cb10c748acb27137a6e7d43 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { createSpyObj, SpyObj } from '../../testing/mocks';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideNoopAnimations } from '@angular/platform-browser/animations';
import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
import { of, throwError } from 'rxjs';
import { NotificationService } from '../../core/services/notification.service';
import { provideIonicTesting } from '../../testing/ionic-testing';
import { provideTranslateTesting } from '../../testing/i18n-testing';
import { ReportExecutionService, ReportParameter, ReportResult } from './report-execution.service';
import { RunReportComponent } from './run-report.component';
describe('RunReportComponent', () => {
const REPORT_NAME = 'Active Clients';
const HEAD_OFFICE = 'Head Office';
const BAR_CHART = '[data-testid="report-chart-bar"]';
const REPORT_TABLE = '[data-testid="report-table"]';
let component: RunReportComponent;
let fixture: ComponentFixture<RunReportComponent>;
let reportExecutionSpy: SpyObj<ReportExecutionService>;
const parameter = (
variable: string,
label: string,
displayType: string,
options: ReportParameter['options'] = [],
overrides: Partial<ReportParameter> = {},
): ReportParameter => ({
name: `${variable}Parameter`,
variable,
label,
displayType,
formatType: displayType === 'date' ? 'date' : 'string',
defaultValue: displayType === 'none' ? 'hidden-value' : null,
selectOne: null,
selectAll: null,
parentParameterName: null,
queryParameter: `R_${variable}`,
options,
optionsFailed: false,
...overrides,
});
const office = parameter('officeId', 'Office', 'select', [{ id: 1, name: HEAD_OFFICE }]);
const loanOfficer = parameter('loanOfficerId', 'Loan Officer', 'select', [
{ id: 42, name: 'Ada Officer' },
]);
const fromDate = parameter('fromDate', 'From Date', 'date');
const toDate = parameter('toDate', 'To Date', 'date');
const accountNumber = parameter('accountNo', 'Account Number', 'text');
beforeEach(async () => {
reportExecutionSpy = createSpyObj([
'getReportParameters',
'runReport',
'downloadCsv',
'getDependentOptions',
]);
await TestBed.configureTestingModule({
imports: [RunReportComponent],
providers: [
...provideTranslateTesting(),
{ provide: ReportExecutionService, useValue: reportExecutionSpy },
{ provide: Router, useValue: createSpyObj(['navigate']) },
{
provide: NotificationService,
useValue: createSpyObj(['error']),
},
{
provide: ActivatedRoute,
useValue: {
paramMap: of(convertToParamMap({ reportName: REPORT_NAME })),
queryParamMap: of(convertToParamMap({ type: 'Table' })),
},
},
provideIonicTesting(),
provideNoopAnimations(),
],
}).compileComponents();
});
function create(
parameters: ReportParameter[],
queryParams: Record<string, string> = { type: 'Table' },
): void {
TestBed.overrideProvider(ActivatedRoute, {
useValue: {
paramMap: of(convertToParamMap({ reportName: REPORT_NAME })),
queryParamMap: of(convertToParamMap(queryParams)),
},
});
reportExecutionSpy.getReportParameters.mockReturnValue(of(parameters));
fixture = TestBed.createComponent(RunReportComponent);
component = fixture.componentInstance;
fixture.detectChanges();
}
function viewOf(variable: string) {
const view = component.parameterViews().find((v) => v.parameter.variable === variable);
if (!view) throw new Error(`No parameter view for ${variable}`);
return view;
}
it('should create and read the report name from the route', () => {
create([office]);
expect(component).toBeTruthy();
expect(component.reportName()).toBe(REPORT_NAME);
expect(reportExecutionSpy.getReportParameters).toHaveBeenCalledExactlyOnceWith(REPORT_NAME);
});
it('renders all five declared visible parameters using their display types', () => {
create([office, loanOfficer, fromDate, toDate, accountNumber]);
const controls = fixture.nativeElement.querySelectorAll(
'ion-item[data-testid^="report-parameter-"]',
);
expect(controls).toHaveLength(5);
expect(
fixture.nativeElement.querySelector('[data-testid="report-parameter-officeId"]'),
).not.toBeNull();
expect(
fixture.nativeElement.querySelector('[data-testid="report-parameter-fromDate"]'),
).not.toBeNull();
expect(
fixture.nativeElement.querySelector('[data-testid="report-parameter-accountNo"]'),
).not.toBeNull();
});
it('renders only Office when it is the only declared parameter', () => {
create([office]);
expect(
fixture.nativeElement.querySelectorAll('[data-testid="report-parameter-officeId"]'),
).toHaveLength(1);
expect(
fixture.nativeElement.querySelectorAll('ion-item[data-testid^="report-parameter-"]'),
).toHaveLength(1);
});
it('passes hidden defaults without rendering a control', () => {
const hidden = parameter('tenantScope', 'Tenant Scope', 'none');
reportExecutionSpy.runReport.mockReturnValue(of({ columnHeaders: [], data: [] }));
create([hidden]);
expect(
fixture.nativeElement.querySelector('[data-testid="report-parameter-tenantScope"]'),
).toBeNull();
expect(component.canRun()).toBe(true);
component.onRun();
expect(reportExecutionSpy.runReport).toHaveBeenCalledExactlyOnceWith(REPORT_NAME, {
R_tenantScope: 'hidden-value',
});
});
it('passes the full collected value map to the report call', () => {
const result: ReportResult = { columnHeaders: [], data: [] };
reportExecutionSpy.runReport.mockReturnValue(of(result));
create([office, loanOfficer, fromDate, toDate, accountNumber]);
component.setParameterValue(office, 1);
component.setParameterValue(loanOfficer, 42);
component.setParameterValue(fromDate, '2026-08-01T00:00:00.000Z');
component.setParameterValue(toDate, '2026-08-09T00:00:00.000Z');
component.setParameterValue(accountNumber, '000123');
component.onRun();
expect(reportExecutionSpy.runReport).toHaveBeenCalledExactlyOnceWith(REPORT_NAME, {
R_officeId: 1,
R_loanOfficerId: 42,
R_fromDate: '2026-08-01',
R_toDate: '2026-08-09',
R_accountNo: '000123',
});
});
it('blocks a report that declares an unsupported display type', () => {
create([parameter('customFilter', 'Custom Filter', 'range')]);
expect(component.canRun()).toBe(false);
expect(
fixture.nativeElement.querySelector('[data-testid="report-parameters-unsupported"]')
.textContent,
).toContain('Custom Filter (range)');
});
describe('dependent parameters', () => {
const currency = parameter('currencyId', 'Currency', 'select', [
{ id: 'USD', name: 'US Dollar' },
{ id: 'EUR', name: 'Euro' },
]);
const product = parameter('loanProductId', 'Product', 'select', [], {
parentParameterName: currency.name,
selectAll: 'Y',
});
const usdProducts = [{ id: 7, name: 'USD Loan' }];
const eurProducts = [{ id: 9, name: 'EUR Loan' }];
it('disables a dependent control until its parent has a value', () => {
create([currency, product]);
expect(viewOf('loanProductId').disabled).toBe(true);
expect(viewOf('loanProductId').waitingForParent).toBe(true);
expect(
fixture.nativeElement.querySelector(
'[data-testid="report-parameter-loanProductId-waiting"]',
),
).not.toBeNull();
// Nothing is fetched for it while it cannot be used.
expect(reportExecutionSpy.getDependentOptions).not.toHaveBeenCalled();
});
it('scopes the child lookup by the parent parameter that carries the value', () => {
reportExecutionSpy.getDependentOptions.mockReturnValue(of(usdProducts));
create([currency, product]);
component.setParameterValue(currency, 'USD');
fixture.detectChanges();
expect(reportExecutionSpy.getDependentOptions).toHaveBeenCalledExactlyOnceWith(
product,
'R_currencyId',
'USD',
);
expect(viewOf('loanProductId').options).toEqual(usdProducts);
expect(viewOf('loanProductId').disabled).toBe(false);
});
/**
* The point of the issue: a stale child selection survives a parent change and silently
* filters the report to something the user did not ask for.
*/
it('clears the child selection when the parent changes, rather than only refetching', () => {
reportExecutionSpy.getDependentOptions.mockReturnValue(of(usdProducts));
create([currency, product]);
component.setParameterValue(currency, 'USD');
component.setParameterValue(product, 7);
expect(component.parameterValue(product)).toBe(7);
reportExecutionSpy.getDependentOptions.mockReturnValue(of(eurProducts));
component.setParameterValue(currency, 'EUR');
fixture.detectChanges();
expect(component.parameterValue(product)).toBeUndefined();
expect(viewOf('loanProductId').options).toEqual(eurProducts);
expect(component.canRun()).toBe(false);
});
it('clears a grandchild when the top of the chain changes', () => {
const purpose = parameter('loanPurposeId', 'Purpose', 'select', [], {
parentParameterName: product.name,
});
reportExecutionSpy.getDependentOptions.mockReturnValue(of(usdProducts));
create([currency, product, purpose]);
component.setParameterValue(currency, 'USD');
component.setParameterValue(product, 7);
component.setParameterValue(purpose, 3);
component.setParameterValue(currency, 'EUR');
expect(component.parameterValue(product)).toBeUndefined();
expect(component.parameterValue(purpose)).toBeUndefined();
});
/**
* A dependent lookup can fail for reasons the user cannot act on — the stock loan-officer
* lookup fails outright on PostgreSQL. An empty dropdown would read as "no products exist".
*/
it('shows an error state on a failed lookup instead of an empty dropdown', () => {
reportExecutionSpy.getDependentOptions.mockReturnValue(throwError(() => new Error('boom')));
create([currency, product]);
component.setParameterValue(currency, 'USD');
fixture.detectChanges();
expect(viewOf('loanProductId').failed).toBe(true);
expect(
fixture.nativeElement.querySelector('[data-testid="report-parameter-loanProductId-error"]'),
).not.toBeNull();
// "All" is declared by the parameter, not discovered by the lookup, so it survives the
// failure and keeps the report runnable unfiltered.
expect(viewOf('loanProductId').options).toEqual([{ id: '-1', name: '', isAll: true }]);
});
it('leaves a failed lookup with no All option unrunnable', () => {
const strict = parameter('branchId', 'Branch', 'select', [], {
parentParameterName: currency.name,
});
reportExecutionSpy.getDependentOptions.mockReturnValue(throwError(() => new Error('boom')));
create([currency, strict]);
component.setParameterValue(currency, 'USD');
fixture.detectChanges();
expect(viewOf('branchId').options).toEqual([]);
expect(component.canRun()).toBe(false);
});
});
describe('report type', () => {
const chartResult: ReportResult = {
columnHeaders: [
{ columnName: 'Office', columnDisplayType: 'STRING' },
{ columnName: 'Clients', columnDisplayType: 'INTEGER' },
],
data: [{ row: [HEAD_OFFICE, 58] }, { row: ['Branch', 2] }],
};
it('renders a bar chart for a chart report rather than a table', () => {
reportExecutionSpy.runReport.mockReturnValue(of(chartResult));
create([], { type: 'Chart', subType: 'Bar' });
component.onRun();
fixture.detectChanges();
expect(component.chartSeries()).toEqual([
{ label: HEAD_OFFICE, value: 58, color: expect.any(String) },
{ label: 'Branch', value: 2, color: expect.any(String) },
]);
expect(fixture.nativeElement.querySelector(BAR_CHART)).not.toBeNull();
expect(fixture.nativeElement.querySelector(REPORT_TABLE)).toBeNull();
});
it('renders the pie sub-type through the donut chart', () => {
reportExecutionSpy.runReport.mockReturnValue(of(chartResult));
create([], { type: 'Chart', subType: 'Pie' });
component.onRun();
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('[data-testid="report-chart-pie"]'),
).not.toBeNull();
expect(fixture.nativeElement.querySelector(BAR_CHART)).toBeNull();
});
it('keeps rendering a table for a table report', () => {
reportExecutionSpy.runReport.mockReturnValue(of(chartResult));
create([], { type: 'Table' });
component.onRun();
fixture.detectChanges();
expect(component.chartSeries()).toEqual([]);
expect(fixture.nativeElement.querySelector(REPORT_TABLE)).not.toBeNull();
expect(fixture.nativeElement.querySelector('[data-testid="report-chart"]')).toBeNull();
});
/** A definition can claim to be a chart and return nothing plottable. */
it('falls back to the table, and says why, when a chart report has no numeric column', () => {
reportExecutionSpy.runReport.mockReturnValue(
of({
columnHeaders: [
{ columnName: 'Office', columnDisplayType: 'STRING' },
{ columnName: 'Status', columnDisplayType: 'STRING' },
],
data: [{ row: [HEAD_OFFICE, 'Active'] }],
}),
);
create([], { type: 'Chart', subType: 'Bar' });
component.onRun();
fixture.detectChanges();
expect(component.chartUnavailable()).toBe(true);
expect(
fixture.nativeElement.querySelector('[data-testid="report-chart-unavailable"]'),
).not.toBeNull();
expect(fixture.nativeElement.querySelector(REPORT_TABLE)).not.toBeNull();
});
});
});