Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 208 additions & 0 deletions src/app/system/birt-report-file-upload.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/

import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { AuthenticationService } from '../core/authentication/authentication.service';
import { HasPermissionDirective } from '../directives/has-permission/has-permission.directive';
import { environment } from '../../environments/environment';
import { SystemService } from './system.service';
import {
MAX_REPORT_DESIGN_SIZE_BYTES,
UploadReportFileDialogComponent
} from './manage-reports/upload-report-file-dialog/upload-report-file-dialog.component';

/** Builds a File of a given size without allocating the bytes twice. */
function fileOf(name: string, size: number): File {
const file = new File(['x'], name);
Object.defineProperty(file, 'size', { value: size });
return file;
}

function changeEvent(file: File | null): any {
return { target: { files: file ? [file] : [] } };
}

describe('SystemService BIRT report design upload', () => {
let service: SystemService;
let httpMock: HttpTestingController;

beforeEach(() => {
TestBed.configureTestingModule({
providers: [
SystemService,
provideHttpClient(),
provideHttpClientTesting()
]
});

service = TestBed.inject(SystemService);
httpMock = TestBed.inject(HttpTestingController);
});

afterEach(() => {
httpMock.verify();
});

it('posts the design as multipart form data to the BIRT reports resource', () => {
const design = fileOf('Active Loans.rptdesign', 2048);

service.uploadBirtReportFile(design).subscribe();

const req = httpMock.expectOne('/birt/reports');
expect(req.request.method).toBe('POST');
expect(req.request.body instanceof FormData).toBe(true);
expect((req.request.body as FormData).get('file')).toBe(design);
req.flush({ fileName: 'Active Loans.rptdesign', size: 2048, overwritten: false });
});

it('leaves the multipart Content-Type to the browser so the boundary is set', () => {
service.uploadBirtReportFile(fileOf('Active Loans.rptdesign', 10)).subscribe();

const req = httpMock.expectOne('/birt/reports');
expect(req.request.headers.get('Content-Type')).toBeNull();
req.flush({});
});

it('surfaces a server rejection to the caller instead of swallowing it', () => {
let status: number | undefined;
service.uploadBirtReportFile(fileOf('Active Loans.rptdesign', 10)).subscribe({
error: (error) => (status = error.status)
});

httpMock.expectOne('/birt/reports').flush({}, { status: 403, statusText: 'Forbidden' });
expect(status).toBe(403);
});
});

describe('UploadReportFileDialogComponent', () => {
let component: UploadReportFileDialogComponent;

beforeEach(() => {
component = new UploadReportFileDialogComponent();
});

it('accepts a .rptdesign file and reports its size', () => {
component.onFileSelect(changeEvent(fileOf('Active Loans.rptdesign', 2048)));

expect(component.file?.name).toBe('Active Loans.rptdesign');
expect(component.errorKey).toBeNull();
expect(component.fileSizeInKb).toBe('2.0');
});

it('accepts a .RPTDESIGN file whatever the case of the extension', () => {
component.onFileSelect(changeEvent(fileOf('Active Loans.RPTDESIGN', 10)));

expect(component.file).not.toBeNull();
});

it.each([
'report.pdf',
'report.xml',
'report.txt',
'report.zip',
'report.json',
'report.prpt',
'report'
])('rejects %s', (name) => {
component.onFileSelect(changeEvent(fileOf(name, 10)));

expect(component.file).toBeNull();
expect(component.errorKey).toBe('labels.text.Only Eclipse BIRT report designs can be uploaded');
});

it('rejects a design larger than the platform accepts', () => {
component.onFileSelect(changeEvent(fileOf('Active Loans.rptdesign', MAX_REPORT_DESIGN_SIZE_BYTES + 1)));

expect(component.file).toBeNull();
expect(component.errorKey).toBe('labels.text.The selected report design is too large');
});

it('rejects an empty design', () => {
component.onFileSelect(changeEvent(fileOf('Active Loans.rptdesign', 0)));

expect(component.file).toBeNull();
expect(component.errorKey).toBe('labels.text.The selected report design is empty');
});

it('clears a previous rejection when a valid design is chosen next', () => {
component.onFileSelect(changeEvent(fileOf('report.pdf', 10)));
component.onFileSelect(changeEvent(fileOf('Active Loans.rptdesign', 10)));

expect(component.errorKey).toBeNull();
expect(component.file).not.toBeNull();
});

it('keeps nothing selected when the picker is dismissed', () => {
component.onFileSelect(changeEvent(fileOf('Active Loans.rptdesign', 10)));
component.onFileSelect(changeEvent(null));

expect(component.file).toBeNull();
expect(component.errorKey).toBeNull();
});
});

/**
* The upload button in Manage Reports is gated on CREATE_REPORT. This exercises that guard through
* the same directive the template uses, rather than rendering the whole reports screen.
*/
@Component({
standalone: true,
imports: [HasPermissionDirective],
template: `<button *mifosxHasPermission="'CREATE_REPORT'" id="upload">Upload Report Design</button>`
})
class UploadButtonHostComponent {}

describe('Upload Report Design button visibility', () => {
let rbacWasEnabled: boolean;

function renderWithPermissions(permissions: string[]): ComponentFixture<UploadButtonHostComponent> {
TestBed.configureTestingModule({
imports: [UploadButtonHostComponent],
providers: [
{ provide: AuthenticationService, useValue: { getCredentials: () => ({ permissions }) } }
]
});
const fixture = TestBed.createComponent(UploadButtonHostComponent);
fixture.detectChanges();
return fixture;
}

function uploadButton(fixture: ComponentFixture<UploadButtonHostComponent>): HTMLElement | null {
return fixture.nativeElement.querySelector('#upload');
}

beforeEach(() => {
rbacWasEnabled = environment.productionModeEnableRBAC;
environment.productionModeEnableRBAC = true;
TestBed.resetTestingModule();
});

afterEach(() => {
environment.productionModeEnableRBAC = rbacWasEnabled;
});

it('is shown to a user with CREATE_REPORT', () => {
expect(uploadButton(renderWithPermissions(['CREATE_REPORT']))).not.toBeNull();
});

it('is shown to a super user', () => {
expect(uploadButton(renderWithPermissions(['ALL_FUNCTIONS']))).not.toBeNull();
});

it('is hidden from a user who may only read reports', () => {
expect(uploadButton(renderWithPermissions(['READ_REPORT']))).toBeNull();
});

it('is hidden from a user with a blanket read grant', () => {
expect(uploadButton(renderWithPermissions(['ALL_FUNCTIONS_READ']))).toBeNull();
});
});
6 changes: 6 additions & 0 deletions src/app/system/manage-reports/manage-reports.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
{{ 'labels.buttons.Create Report' | translate }}
</button>
</div>
<div class="in-block">
<button mat-raised-button color="primary" (click)="uploadReportDesign()" *mifosxHasPermission="'CREATE_REPORT'">
<fa-icon icon="upload" class="m-r-10"></fa-icon>
Comment thread
IOhacker marked this conversation as resolved.
{{ 'labels.buttons.Upload Report Design' | translate }}
</button>
</div>
</div>

<div class="container">
Expand Down
33 changes: 33 additions & 0 deletions src/app/system/manage-reports/manage-reports.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,17 @@ import {
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';

import { TranslateService } from '@ngx-translate/core';

/* Custom Services */
import { PopoverService } from '../../configuration-wizard/popover/popover.service';
import { ConfigurationWizardService } from '../../configuration-wizard/configuration-wizard.service';
import { SystemService } from '../system.service';
import { AlertService } from '../../core/alert/alert.service';

/** Custom Dialog Component */
import { CompletionDialogComponent } from '../../configuration-wizard/completion-dialog/completion-dialog.component';
import { UploadReportFileDialogComponent } from './upload-report-file-dialog/upload-report-file-dialog.component';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
Expand Down Expand Up @@ -78,6 +83,9 @@ export class ManageReportsComponent implements OnInit, AfterViewInit {
private configurationWizardService = inject(ConfigurationWizardService);
private popoverService = inject(PopoverService);
private dialog = inject(MatDialog);
private systemService = inject(SystemService);
private alertService = inject(AlertService);
private translateService = inject(TranslateService);

/** Reports Data. */
reportsData: any;
Expand Down Expand Up @@ -141,6 +149,31 @@ export class ManageReportsComponent implements OnInit, AfterViewInit {
this.dataSource.filter = filterValue.trim().toLowerCase();
}

/**
* Installs an Eclipse BIRT report design on the server.
*
* Only the success case is announced here. Every failure the server returns already reaches the
* user through the global error interceptor, with the server's own message, so reporting it again
* would show the same problem twice.
*/
uploadReportDesign(): void {
const dialogRef = this.dialog.open(UploadReportFileDialogComponent, { width: '33rem' });
dialogRef.afterClosed().subscribe((response: any) => {
if (!response?.file) {
return;
}
this.systemService.uploadBirtReportFile(response.file).subscribe((result: any) => {
this.alertService.alert({
type: 'Report Design Uploaded',
message: this.translateService.instant(
result?.overwritten ? 'labels.text.Report design replaced' : 'labels.text.Report design uploaded',
{ fileName: result?.fileName ?? response.file.name }
)
});
});
});
}

/**
* To show popover.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!--
Copyright since 2025 Mifos Initiative

This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->

<h1 mat-dialog-title>{{ 'labels.heading.Upload Report Design' | translate }}</h1>

<div class="m-l-10 m-r-10">
<p class="report-design-hint">{{ 'labels.text.Upload a BIRT report design' | translate }}</p>

<mifosx-file-upload flex="60%" [acceptFilter]="acceptFilter" (change)="onFileSelect($event)"></mifosx-file-upload>

@if (file) {
<p class="report-design-summary">
<span class="report-design-name">{{ file.name }}</span>
<span class="report-design-size">{{ fileSizeInKb }} {{ 'labels.text.KB' | translate }}</span>
</p>
}

@if (errorKey) {
<p class="report-design-error">{{ errorKey | translate }}</p>
}

<mat-dialog-actions align="end">
<button mat-raised-button mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
<button mat-raised-button color="primary" [disabled]="!file" [mat-dialog-close]="{ file: file }">
{{ 'labels.buttons.Upload' | translate }}
</button>
</mat-dialog-actions>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/

.report-design-hint {
color: var(--mat-sys-on-surface-variant);
font-size: 0.85rem;
}

.report-design-summary {
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: 0.85rem;
}

.report-design-name {
overflow-wrap: anywhere;
font-weight: 500;
}

.report-design-size {
white-space: nowrap;
color: var(--mat-sys-on-surface-variant);
}

.report-design-error {
color: var(--mat-sys-error);
font-size: 0.85rem;
}
Loading
Loading