diff --git a/quartz-manager-frontend/.eslintrc.sonar.json b/quartz-manager-frontend/.eslintrc.sonar.json new file mode 100644 index 0000000..d98850c --- /dev/null +++ b/quartz-manager-frontend/.eslintrc.sonar.json @@ -0,0 +1,19 @@ +{ + "env": { + "browser": true, + "es6": true, + "node": true + }, + "extends": [ + "plugin:sonarjs/recommended" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "project": "tsconfig.json", + "sourceType": "module" + }, + "plugins": [ + "sonarjs" + ], + "root": true +} diff --git a/quartz-manager-frontend/package-lock.json b/quartz-manager-frontend/package-lock.json index f5bb1db..90398ad 100644 --- a/quartz-manager-frontend/package-lock.json +++ b/quartz-manager-frontend/package-lock.json @@ -62,6 +62,7 @@ "eslint-config-prettier": "^8.5.0", "eslint-plugin-import": "^2.26.0", "eslint-plugin-prettier": "^4.2.1", + "eslint-plugin-sonarjs": "^0.16.0", "jasmine-core": "~4.5.0", "jasmine-spec-reporter": "~7.0.0", "jest": "28.1.3", @@ -8604,6 +8605,18 @@ } } }, + "node_modules/eslint-plugin-sonarjs": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-0.16.0.tgz", + "integrity": "sha512-al8ojAzcQW8Eu0tWn841ldhPpPcjrJ59TzzTfAVWR45bWvdAASCmrGl8vK0MWHyKVDdC0i17IGbtQQ1KgxLlVA==", + "dev": true, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "eslint": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/eslint-scope": { "version": "5.1.1", "dev": true, @@ -26048,6 +26061,13 @@ "prettier-linter-helpers": "^1.0.0" } }, + "eslint-plugin-sonarjs": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-0.16.0.tgz", + "integrity": "sha512-al8ojAzcQW8Eu0tWn841ldhPpPcjrJ59TzzTfAVWR45bWvdAASCmrGl8vK0MWHyKVDdC0i17IGbtQQ1KgxLlVA==", + "dev": true, + "requires": {} + }, "eslint-scope": { "version": "5.1.1", "dev": true, diff --git a/quartz-manager-frontend/package.json b/quartz-manager-frontend/package.json index b390f5b..77c9d3b 100644 --- a/quartz-manager-frontend/package.json +++ b/quartz-manager-frontend/package.json @@ -8,6 +8,8 @@ "build": "ng build --configuration production", "test": "jest", "lint": "ng lint", + "lint:sonar": "eslint --no-eslintrc -c .eslintrc.sonar.json \"src/**/*.ts\"", + "lint:sonar:fix": "eslint --no-eslintrc -c .eslintrc.sonar.json \"src/**/*.ts\" --fix", "e2e": "ng e2e" }, "private": true, @@ -65,6 +67,7 @@ "eslint-config-prettier": "^8.5.0", "eslint-plugin-import": "^2.26.0", "eslint-plugin-prettier": "^4.2.1", + "eslint-plugin-sonarjs": "^0.16.0", "jasmine-core": "~4.5.0", "jasmine-spec-reporter": "~7.0.0", "jest": "28.1.3", diff --git a/quartz-manager-frontend/src/_test.ts b/quartz-manager-frontend/src/_test.ts index 9bf7226..1cfaeb5 100644 --- a/quartz-manager-frontend/src/_test.ts +++ b/quartz-manager-frontend/src/_test.ts @@ -13,8 +13,8 @@ import { } from '@angular/platform-browser-dynamic/testing'; // Unfortunately there's no typing for the `__karma__` variable. Just declare it as any. -declare var __karma__: any; -declare var require: any; +declare let __karma__: any; +declare let require: any; // Prevent Karma from running prematurely. __karma__.loaded = function () {}; diff --git a/quartz-manager-frontend/src/app/components/logs-panel/logs-panel.component.spec.ts b/quartz-manager-frontend/src/app/components/logs-panel/logs-panel.component.spec.ts new file mode 100644 index 0000000..d157604 --- /dev/null +++ b/quartz-manager-frontend/src/app/components/logs-panel/logs-panel.component.spec.ts @@ -0,0 +1,64 @@ +import {Subject} from 'rxjs'; +import {LogsPanelComponent} from './logs-panel.component'; +import {TriggerKey} from '../../model/triggerKey.model'; +import {jest} from '@jest/globals'; + +describe('LogsPanelComponent', () => { + + it('should subscribe to the selected trigger logs topic', () => { + const messages = new Subject(); + const logsRxWebsocketService = { + watch: jest.fn(() => messages.asObservable()) + }; + const component = new LogsPanelComponent(logsRxWebsocketService as any, null); + + component.triggerKey = new TriggerKey('trigger-1', null); + + expect(logsRxWebsocketService.watch).toHaveBeenCalledWith('/topic/logs/trigger-1'); + + const logRecord = { + date: new Date(), + type: 'INFO', + message: 'job completed', + threadName: 'worker-1' + }; + messages.next({body: JSON.stringify(logRecord)}); + + expect(component.logs[0]).toEqual({ + time: logRecord.date.toISOString(), + type: 'INFO', + msg: 'job completed', + threadName: 'worker-1' + }); + }); + + it('should unsubscribe from the previous topic when the trigger changes', () => { + const firstMessages = new Subject(); + const secondMessages = new Subject(); + const logsRxWebsocketService = { + watch: jest.fn() + .mockReturnValueOnce(firstMessages.asObservable()) + .mockReturnValueOnce(secondMessages.asObservable()) + }; + const component = new LogsPanelComponent(logsRxWebsocketService as any, null); + + component.triggerKey = new TriggerKey('trigger-1', null); + const firstSubscription = component.topicSubscription; + jest.spyOn(firstSubscription, 'unsubscribe'); + + component.triggerKey = new TriggerKey('trigger-2', null); + + expect(firstSubscription.unsubscribe).toHaveBeenCalled(); + expect(logsRxWebsocketService.watch).toHaveBeenCalledWith('/topic/logs/trigger-2'); + }); + + it('should ignore destroy when no topic was selected', () => { + const logsRxWebsocketService = { + watch: jest.fn() + }; + const component = new LogsPanelComponent(logsRxWebsocketService as any, null); + + expect(() => component.ngOnDestroy()).not.toThrow(); + }); + +}); diff --git a/quartz-manager-frontend/src/app/components/logs-panel/logs-panel.component.ts b/quartz-manager-frontend/src/app/components/logs-panel/logs-panel.component.ts index ad390d2..4e2f292 100644 --- a/quartz-manager-frontend/src/app/components/logs-panel/logs-panel.component.ts +++ b/quartz-manager-frontend/src/app/components/logs-panel/logs-panel.component.ts @@ -27,37 +27,42 @@ export class LogsPanelComponent implements OnInit, OnDestroy { ) { } - @Input() - set triggerKey(triggerKey: TriggerKey) { - this.selectedTriggerKey = {...triggerKey} as TriggerKey; - if (this.selectedTriggerKey && this.selectedTriggerKey.name) { - this._subscribeToTheTopic(this.selectedTriggerKey); - } - } + @Input() + set triggerKey(triggerKey: TriggerKey) { + if (!triggerKey || !triggerKey.name) { + this._unsubscribeFromTopic(); + this.selectedTriggerKey = null; + return; + } + + this.selectedTriggerKey = {...triggerKey} as TriggerKey; + this._subscribeToTheTopic(this.selectedTriggerKey); + } ngOnInit() { } - private _subscribeToTheTopic = (triggerKey: TriggerKey) => { - if (this.topicSubscription) { - this.topicSubscription.unsubscribe(); - } - this.topicSubscription = this.logsRxWebsocketService.watch(`/topic/logs/${triggerKey.name}`) - .pipe(map(msg => JSON.parse(msg.body))) + private _subscribeToTheTopic = (triggerKey: TriggerKey) => { + this._unsubscribeFromTopic(); + this.topicSubscription = this.logsRxWebsocketService.watch(`/topic/logs/${triggerKey.name}`) + .pipe(map((msg: any) => JSON.parse(msg.body))) .subscribe(this._showNewLog, (err) => { console.log(err); // TODO in case of 401 // this.apiService.get('/quartz-manager/session/refresh'); }); - }; - - ngOnDestroy() { - if (this.topicSubscription) { - this.topicSubscription.unsubscribe(); - } - this.topicSubscription.unsubscribe(); - this.topicSubscription = null; - } + }; + + ngOnDestroy() { + this._unsubscribeFromTopic(); + } + + private _unsubscribeFromTopic() { + if (this.topicSubscription) { + this.topicSubscription.unsubscribe(); + this.topicSubscription = null; + } + } _showNewLog = (logRecord) => { if (this.logs.length > this.MAX_LOGS) { diff --git a/quartz-manager-frontend/src/app/components/progress-panel/progress-panel.component.spec.ts b/quartz-manager-frontend/src/app/components/progress-panel/progress-panel.component.spec.ts new file mode 100644 index 0000000..d842cca --- /dev/null +++ b/quartz-manager-frontend/src/app/components/progress-panel/progress-panel.component.spec.ts @@ -0,0 +1,54 @@ +import {Subject} from 'rxjs'; +import {ProgressPanelComponent} from './progress-panel.component'; +import {TriggerKey} from '../../model/triggerKey.model'; +import {jest} from '@jest/globals'; + +describe('ProgressPanelComponent', () => { + + it('should subscribe to the selected trigger progress topic', () => { + const messages = new Subject(); + const progressRxWebsocketService = { + watch: jest.fn(() => messages.asObservable()) + }; + const component = new ProgressPanelComponent(progressRxWebsocketService as any); + + component.triggerKey = new TriggerKey('trigger-1', null); + + expect(progressRxWebsocketService.watch).toHaveBeenCalledWith('/topic/progress/trigger-1'); + + messages.next({body: JSON.stringify({percentage: 75, timesTriggered: 3})}); + + expect(component.progress.percentage).toEqual(75); + expect(component.percentageStr).toEqual('75%'); + }); + + it('should unsubscribe from the previous topic when the trigger changes', () => { + const firstMessages = new Subject(); + const secondMessages = new Subject(); + const progressRxWebsocketService = { + watch: jest.fn() + .mockReturnValueOnce(firstMessages.asObservable()) + .mockReturnValueOnce(secondMessages.asObservable()) + }; + const component = new ProgressPanelComponent(progressRxWebsocketService as any); + + component.triggerKey = new TriggerKey('trigger-1', null); + const firstSubscription = component.topicSubscription; + jest.spyOn(firstSubscription, 'unsubscribe'); + + component.triggerKey = new TriggerKey('trigger-2', null); + + expect(firstSubscription.unsubscribe).toHaveBeenCalled(); + expect(progressRxWebsocketService.watch).toHaveBeenCalledWith('/topic/progress/trigger-2'); + }); + + it('should ignore destroy when no topic was selected', () => { + const progressRxWebsocketService = { + watch: jest.fn() + }; + const component = new ProgressPanelComponent(progressRxWebsocketService as any); + + expect(() => component.ngOnDestroy()).not.toThrow(); + }); + +}); diff --git a/quartz-manager-frontend/src/app/components/progress-panel/progress-panel.component.ts b/quartz-manager-frontend/src/app/components/progress-panel/progress-panel.component.ts index 8cdc5ba..6713884 100644 --- a/quartz-manager-frontend/src/app/components/progress-panel/progress-panel.component.ts +++ b/quartz-manager-frontend/src/app/components/progress-panel/progress-panel.component.ts @@ -21,20 +21,22 @@ export class ProgressPanelComponent implements OnInit, OnDestroy { private progressRxWebsocketService: ProgressRxWebsocketService ) { } - @Input() - set triggerKey(triggerKey: TriggerKey) { - this.selectedTriggerKey = {...triggerKey} as TriggerKey; - if (this.selectedTriggerKey && this.selectedTriggerKey.name) { - this._subscribeToTheTopic(this.selectedTriggerKey); - } - } - - private _subscribeToTheTopic = (triggerKey: TriggerKey) => { - if (this.topicSubscription) { - this.topicSubscription.unsubscribe(); - } - this.topicSubscription = this.progressRxWebsocketService.watch(`/topic/progress/${triggerKey.name}`) - .pipe(map(msg => JSON.parse(msg.body))) + @Input() + set triggerKey(triggerKey: TriggerKey) { + if (!triggerKey || !triggerKey.name) { + this._unsubscribeFromTopic(); + this.selectedTriggerKey = null; + return; + } + + this.selectedTriggerKey = {...triggerKey} as TriggerKey; + this._subscribeToTheTopic(this.selectedTriggerKey); + } + + private _subscribeToTheTopic = (triggerKey: TriggerKey) => { + this._unsubscribeFromTopic(); + this.topicSubscription = this.progressRxWebsocketService.watch(`/topic/progress/${triggerKey.name}`) + .pipe(map((msg: any) => JSON.parse(msg.body))) .subscribe(this.onNewProgressMsg, (err) => { console.log(err); // TODO in case of 401 @@ -48,14 +50,17 @@ export class ProgressPanelComponent implements OnInit, OnDestroy { } ngOnInit() { - } - - ngOnDestroy() { - if (this.topicSubscription) { - this.topicSubscription.unsubscribe(); - } - this.topicSubscription.unsubscribe(); - this.topicSubscription = null; - } - -} + } + + ngOnDestroy() { + this._unsubscribeFromTopic(); + } + + private _unsubscribeFromTopic() { + if (this.topicSubscription) { + this.topicSubscription.unsubscribe(); + this.topicSubscription = null; + } + } + +} diff --git a/quartz-manager-frontend/src/app/components/scheduler-control/scheduler-control.component.spec.ts b/quartz-manager-frontend/src/app/components/scheduler-control/scheduler-control.component.spec.ts index 266fe11..2245bb0 100644 --- a/quartz-manager-frontend/src/app/components/scheduler-control/scheduler-control.component.spec.ts +++ b/quartz-manager-frontend/src/app/components/scheduler-control/scheduler-control.component.spec.ts @@ -13,6 +13,12 @@ import {MatDividerModule} from '@angular/material/divider'; describe('SchedulerControlComponent', () => { + const schedulerUrl = '/quartz-manager/scheduler'; + const schedulerButtonSelector = '#schedulerControllerBtn'; + const schedulerName = 'test-scheduler'; + const schedulerId = 'test-id'; + const stoppedStatus = 'STOPPED'; + let component: SchedulerControlComponent; let fixture: ComponentFixture; @@ -38,16 +44,16 @@ describe('SchedulerControlComponent', () => { it('should display the play button at the beginning since the scheduler is stopped', () => { expect(component).toBeDefined(); - const getSchedulerReq = httpTestingController.expectOne('/quartz-manager/scheduler'); - const mockScheduler = new Scheduler('test-scheduler', 'test-id', 'STOPPED', []); + const getSchedulerReq = httpTestingController.expectOne(schedulerUrl); + const mockScheduler = new Scheduler(schedulerName, schedulerId, stoppedStatus, []); getSchedulerReq.flush(mockScheduler); expect(component.scheduler).toEqual(mockScheduler); - expect(component.scheduler.status).toEqual('STOPPED'); + expect(component.scheduler.status).toEqual(stoppedStatus); fixture.detectChanges(); const schedulerControlComponentDe: DebugElement = fixture.debugElement; - const schedulerBtnDe = schedulerControlComponentDe.query(By.css('#schedulerControllerBtn')); + const schedulerBtnDe = schedulerControlComponentDe.query(By.css(schedulerButtonSelector)); expect(schedulerBtnDe).toBeTruthy(); const playIconDe = schedulerBtnDe.query(By.css('.fa-play')); @@ -56,13 +62,13 @@ describe('SchedulerControlComponent', () => { it('should switch the button to pause when the scheduler is started', () => { expect(component).toBeDefined(); - const getSchedulerReq = httpTestingController.expectOne('/quartz-manager/scheduler'); - const mockScheduler = new Scheduler('test-scheduler', 'test-id', 'STOPPED', []); + const getSchedulerReq = httpTestingController.expectOne(schedulerUrl); + const mockScheduler = new Scheduler(schedulerName, schedulerId, stoppedStatus, []); getSchedulerReq.flush(mockScheduler); fixture.detectChanges(); const schedulerControlComponentDe: DebugElement = fixture.debugElement; - let schedulerBtnDe = schedulerControlComponentDe.query(By.css('#schedulerControllerBtn')); + let schedulerBtnDe = schedulerControlComponentDe.query(By.css(schedulerButtonSelector)); expect(schedulerBtnDe).toBeTruthy(); const playIconDe = schedulerBtnDe.query(By.css('.fa-play')); expect(playIconDe).toBeTruthy(); @@ -72,7 +78,7 @@ describe('SchedulerControlComponent', () => { startSchedulerReq.flush(null); fixture.detectChanges(); - schedulerBtnDe = schedulerControlComponentDe.query(By.css('#schedulerControllerBtn')); + schedulerBtnDe = schedulerControlComponentDe.query(By.css(schedulerButtonSelector)); const pauseIconDe = schedulerBtnDe.query(By.css('.fa-pause')); expect(pauseIconDe).toBeTruthy(); @@ -80,13 +86,13 @@ describe('SchedulerControlComponent', () => { it('should switch the button to play when the scheduler is stopped', () => { expect(component).toBeDefined(); - const getSchedulerReq = httpTestingController.expectOne('/quartz-manager/scheduler'); - const mockScheduler = new Scheduler('test-scheduler', 'test-id', 'RUNNING', []); + const getSchedulerReq = httpTestingController.expectOne(schedulerUrl); + const mockScheduler = new Scheduler(schedulerName, schedulerId, 'RUNNING', []); getSchedulerReq.flush(mockScheduler); fixture.detectChanges(); const schedulerControlComponentDe: DebugElement = fixture.debugElement; - let schedulerBtnDe = schedulerControlComponentDe.query(By.css('#schedulerControllerBtn')); + let schedulerBtnDe = schedulerControlComponentDe.query(By.css(schedulerButtonSelector)); expect(schedulerBtnDe).toBeTruthy(); const pauseIconDe = schedulerBtnDe.query(By.css('.fa-pause')); expect(pauseIconDe).toBeTruthy(); @@ -96,7 +102,7 @@ describe('SchedulerControlComponent', () => { startSchedulerReq.flush(null); fixture.detectChanges(); - schedulerBtnDe = schedulerControlComponentDe.query(By.css('#schedulerControllerBtn')); + schedulerBtnDe = schedulerControlComponentDe.query(By.css(schedulerButtonSelector)); const playIconDe = schedulerBtnDe.query(By.css('.fa-play')); expect(playIconDe).toBeTruthy(); diff --git a/quartz-manager-frontend/src/app/components/simple-trigger-config/simple-trigger-config.component.spec.ts b/quartz-manager-frontend/src/app/components/simple-trigger-config/simple-trigger-config.component.spec.ts index d186a80..422752f 100644 --- a/quartz-manager-frontend/src/app/components/simple-trigger-config/simple-trigger-config.component.spec.ts +++ b/quartz-manager-frontend/src/app/components/simple-trigger-config/simple-trigger-config.component.spec.ts @@ -23,6 +23,11 @@ import {MisfireInstruction} from '../../model/misfire-instruction.model'; describe('SimpleTriggerConfig', () => { + const submitButtonSelector = 'form button[color="primary"]'; + const repeatIntervalSelector = '#repeatInterval'; + const testTriggerName = 'test-trigger'; + const testJobName = 'TestJob'; + let component: SimpleTriggerConfigComponent; let fixture: ComponentFixture; @@ -91,16 +96,16 @@ describe('SimpleTriggerConfig', () => { fixture.detectChanges(); const getJobsReq = httpTestingController.expectOne(`${CONTEXT_PATH}/jobs`); - getJobsReq.flush(['TestJob']); + getJobsReq.flush([testJobName]); const componentDe: DebugElement = fixture.debugElement; - const submitButton = componentDe.query(By.css('form button[color="primary"]')); + const submitButton = componentDe.query(By.css(submitButtonSelector)); expect(submitButton.nativeElement.textContent.trim()).toEqual('Submit'); expect(submitButton.nativeElement.getAttribute('disabled')).toEqual(''); - setInputValue(componentDe, '#triggerName', 'test-trigger'); - expect(component.simpleTriggerReactiveForm.controls.triggerName.value).toEqual('test-trigger'); + setInputValue(componentDe, '#triggerName', testTriggerName); + expect(component.simpleTriggerReactiveForm.controls.triggerName.value).toEqual(testTriggerName); expect(submitButton.nativeElement.getAttribute('disabled')).toEqual(''); setMatSelectValueByIndex(componentDe, '#misfireInstruction', 0); expect(component.simpleTriggerReactiveForm.controls.misfireInstruction.value).toEqual('MISFIRE_INSTRUCTION_FIRE_NOW'); @@ -111,7 +116,7 @@ describe('SimpleTriggerConfig', () => { setInputValue(componentDe, '#repeatCount', '1000'); expect(submitButton.nativeElement.getAttribute('disabled')).toEqual(''); - setInputValue(componentDe, '#repeatInterval', '2000'); + setInputValue(componentDe, repeatIntervalSelector, '2000'); expect(submitButton.nativeElement.getAttribute('disabled')).toEqual(null); } @@ -122,18 +127,18 @@ describe('SimpleTriggerConfig', () => { it('should emit an event when a new trigger is submitted', () => { const componentDe: DebugElement = fixture.debugElement; const mockTrigger = new Trigger(); - mockTrigger.triggerKeyDTO = new TriggerKey('test-trigger', null); - mockTrigger.jobDetailDTO = {jobClassName: 'TestJob', description: null}; + mockTrigger.triggerKeyDTO = new TriggerKey(testTriggerName, null); + mockTrigger.jobDetailDTO = {jobClassName: testJobName, description: null}; mockTrigger.misfireInstruction = MisfireInstruction.MISFIRE_INSTRUCTION_FIRE_NOW; openFormAndFillAllMandatoryFields(); - setInputValue(componentDe, '#repeatInterval', '2000'); + setInputValue(componentDe, repeatIntervalSelector, '2000'); expect(component.simpleTriggerReactiveForm.controls.triggerRecurrence.value.repeatInterval).toEqual(2000); setInputValue(componentDe, '#repeatCount', '100'); expect(component.simpleTriggerReactiveForm.controls.triggerRecurrence.value.repeatCount).toEqual(100); - const submitButton = componentDe.query(By.css('form button[color="primary"]')); + const submitButton = componentDe.query(By.css(submitButtonSelector)); expect(submitButton.nativeElement.textContent.trim()).toEqual('Submit'); let actualNewTrigger; @@ -141,28 +146,28 @@ describe('SimpleTriggerConfig', () => { submitButton.nativeElement.click(); - const postSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/test-trigger`); + const postSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/${testTriggerName}`); postSimpleTriggerReq.flush(mockTrigger); expect(actualNewTrigger).toEqual(mockTrigger); }); it('should not emit an event when an existing trigger is edited', () => { - const mockTriggerKey = new TriggerKey('test-trigger', null); + const mockTriggerKey = new TriggerKey(testTriggerName, null); component.triggerKey = mockTriggerKey; fixture.detectChanges(); const mockTrigger = new SimpleTrigger(); - mockTrigger.triggerKeyDTO = new TriggerKey('test-trigger', null); - mockTrigger.jobDetailDTO = {jobClassName: 'TestJob', description: null}; + mockTrigger.triggerKeyDTO = new TriggerKey(testTriggerName, null); + mockTrigger.jobDetailDTO = {jobClassName: testJobName, description: null}; mockTrigger.mayFireAgain = true; mockTrigger.misfireInstruction = MisfireInstruction.MISFIRE_INSTRUCTION_FIRE_NOW; - const getSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/test-trigger`); + const getSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/${testTriggerName}`); getSimpleTriggerReq.flush(mockTrigger); component.simpleTriggerReactiveForm.setValue({ - triggerName: 'test-trigger', - jobClass: 'TestJob', + triggerName: testTriggerName, + jobClass: testJobName, triggerRecurrence: { repeatInterval: 2000, repeatCount: 100, @@ -178,10 +183,10 @@ describe('SimpleTriggerConfig', () => { fixture.detectChanges(); const componentDe: DebugElement = fixture.debugElement; - setInputValue(componentDe, '#repeatInterval', '4000'); + setInputValue(componentDe, repeatIntervalSelector, '4000'); expect(component.simpleTriggerReactiveForm.controls.triggerRecurrence.value.repeatInterval).toEqual(4000); - const submitButton = componentDe.query(By.css('form button[color="primary"]')); + const submitButton = componentDe.query(By.css(submitButtonSelector)); expect(submitButton.nativeElement.textContent.trim()).toEqual('Submit'); let actualNewTrigger; @@ -189,7 +194,7 @@ describe('SimpleTriggerConfig', () => { submitButton.nativeElement.click(); - const putSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/test-trigger`); + const putSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/${testTriggerName}`); putSimpleTriggerReq.flush(mockTrigger); expect(actualNewTrigger).toBeUndefined(); @@ -220,7 +225,7 @@ describe('SimpleTriggerConfig', () => { fixture.detectChanges(); const componentDe: DebugElement = fixture.debugElement; - const submitButton = componentDe.query(By.css('form button[color="primary"]')); + const submitButton = componentDe.query(By.css(submitButtonSelector)); expect(submitButton.nativeElement.textContent.trim()).toEqual('Submit'); expect(component.simpleTriggerReactiveForm.value.triggerName).toBeNull(); @@ -228,6 +233,25 @@ describe('SimpleTriggerConfig', () => { }); it('should reset the form when a new trigger is selected', () => { + const mockTriggerKey = new TriggerKey(testTriggerName, null); + component.triggerKey = mockTriggerKey; + + const mockTrigger = new SimpleTrigger(); + mockTrigger.triggerKeyDTO = mockTriggerKey; + mockTrigger.jobDetailDTO = {jobClassName: testJobName, description: null}; + mockTrigger.mayFireAgain = true; + mockTrigger.misfireInstruction = MisfireInstruction.MISFIRE_INSTRUCTION_FIRE_NOW; + + const getSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/${testTriggerName}`); + getSimpleTriggerReq.flush(mockTrigger); + + expect(component.simpleTriggerReactiveForm.value.triggerName).toEqual(testTriggerName); + + component.triggerKey = null; + + expect(component.simpleTriggerReactiveForm.value.triggerName).toBeNull(); + expect(component.simpleTriggerReactiveForm.value.jobClass).toBeNull(); + expect(component.shouldShowTheTriggerCardContent()).toBeTruthy(); }); diff --git a/quartz-manager-frontend/src/app/components/simple-trigger-config/simple-trigger-config.component.ts b/quartz-manager-frontend/src/app/components/simple-trigger-config/simple-trigger-config.component.ts index 000d7a4..99ae80f 100644 --- a/quartz-manager-frontend/src/app/components/simple-trigger-config/simple-trigger-config.component.ts +++ b/quartz-manager-frontend/src/app/components/simple-trigger-config/simple-trigger-config.component.ts @@ -47,6 +47,9 @@ export class SimpleTriggerConfigComponent implements OnInit { @Output() onNewTrigger = new EventEmitter(); + @Output() + triggerFormOpenChange = new EventEmitter(); + constructor( private formBuilder: UntypedFormBuilder, private schedulerService: SchedulerService, @@ -63,30 +66,31 @@ export class SimpleTriggerConfigComponent implements OnInit { } openTriggerForm() { - // this.selectedTriggerKey = null; - // this.trigger = null; - // this.simpleTriggerReactiveForm.setValue(new SimpleTriggerReactiveForm()); this.enabledTriggerForm = true; + this.triggerFormOpenChange.emit(this.enabledTriggerForm); } private closeTriggerForm() { this.enabledTriggerForm = false; + this.triggerFormOpenChange.emit(this.enabledTriggerForm); } @Input() set triggerKey(triggerKey: TriggerKey) { if (!triggerKey) { - this.selectedTriggerKey = null; - this.trigger = null; - this.simpleTriggerReactiveForm.reset(new SimpleTriggerReactiveForm()); + this.openNewTriggerForm(); } else if (!this.selectedTriggerKey || this.selectedTriggerKey.name !== triggerKey.name) { this._resetTheTrigger(); this.selectedTriggerKey = {...triggerKey} as TriggerKey; this.fetchSelectedTrigger(); + this.closeTriggerForm(); } - this.openTriggerForm(); } + openNewTriggerForm() { + this._resetTheTrigger(); + this.openTriggerForm(); + } private _resetTheTrigger() { this.trigger = null; @@ -111,7 +115,11 @@ export class SimpleTriggerConfigComponent implements OnInit { existsATriggerInProgress = (): boolean => this.trigger && this.triggerInProgress; onResetReactiveForm = () => { - this.simpleTriggerReactiveForm.setValue(this._fromTriggerToReactiveForm(this.trigger)); + if (this.trigger) { + this.simpleTriggerReactiveForm.setValue(this._fromTriggerToReactiveForm(this.trigger)); + } else { + this.simpleTriggerReactiveForm.reset(new SimpleTriggerReactiveForm()); + } this.closeTriggerForm(); }; @@ -135,8 +143,13 @@ export class SimpleTriggerConfigComponent implements OnInit { this.closeTriggerForm(); }, error => { - this.simpleTriggerReactiveForm.setValue(this._fromTriggerToReactiveForm(this.trigger)); - }, () => {this.triggerLoading = true}); + if (this.trigger) { + this.simpleTriggerReactiveForm.setValue(this._fromTriggerToReactiveForm(this.trigger)); + } + this.triggerLoading = false; + }, () => { +this.triggerLoading = false +}); } diff --git a/quartz-manager-frontend/src/app/polyfills.ts b/quartz-manager-frontend/src/app/polyfills.ts index 622efa0..766305d 100644 --- a/quartz-manager-frontend/src/app/polyfills.ts +++ b/quartz-manager-frontend/src/app/polyfills.ts @@ -14,7 +14,7 @@ * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html */ -/*************************************************************************************************** +/** ************************************************************************************************* * BROWSER POLYFILLS */ @@ -51,14 +51,14 @@ import 'core-js/es7/reflect'; -/*************************************************************************************************** +/** ************************************************************************************************* * Zone JS is required by Angular itself. */ import 'zone.js/dist/zone'; // Included with Angular CLI. -/*************************************************************************************************** +/** ************************************************************************************************* * APPLICATION IMPORTS */ @@ -68,7 +68,7 @@ import 'zone.js/dist/zone'; // Included with Angular CLI. */ // import 'intl'; // Run `npm install --save intl`. -/*************************************************************************************************** +/** ************************************************************************************************* * MATERIAL 2 */ import 'hammerjs/hammer'; diff --git a/quartz-manager-frontend/src/app/services/logs.rx-websocket.service.spec.ts b/quartz-manager-frontend/src/app/services/logs.rx-websocket.service.spec.ts index c208cce..64fa05e 100644 --- a/quartz-manager-frontend/src/app/services/logs.rx-websocket.service.spec.ts +++ b/quartz-manager-frontend/src/app/services/logs.rx-websocket.service.spec.ts @@ -2,15 +2,22 @@ import { TestBed } from '@angular/core/testing'; import { LogsRxWebsocketService } from './logs.rx-websocket.service'; import {ApiService} from './api.service'; -import {HttpClientTestingModule} from '@angular/common/http/testing'; +import {RxStomp} from '@stomp/rx-stomp'; +import {jest} from '@jest/globals'; describe('LogsRxWebsocketService', () => { let service: LogsRxWebsocketService; + let configureSpy; + let activateSpy; beforeEach(() => { + configureSpy = jest.spyOn(RxStomp.prototype, 'configure'); + activateSpy = jest.spyOn(RxStomp.prototype, 'activate').mockImplementation(() => undefined); + TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - providers: [ApiService] + providers: [ + {provide: ApiService, useValue: {getToken: () => 'test-token'}} + ] }); service = TestBed.inject(LogsRxWebsocketService); }); @@ -18,4 +25,15 @@ describe('LogsRxWebsocketService', () => { it('should be created', () => { expect(service).toBeTruthy(); }); + + it('should configure rx-stomp with the logs websocket endpoint', () => { + expect(configureSpy).toHaveBeenCalled(); + expect(activateSpy).toHaveBeenCalled(); + + const config = configureSpy.mock.calls[configureSpy.mock.calls.length - 1][0]; + expect(config.heartbeatIncoming).toEqual(0); + expect(config.heartbeatOutgoing).toEqual(20000); + expect(config.reconnectDelay).toEqual(200); + expect(config.webSocketFactory.toString()).toContain('/logs?access_token='); + }); }); diff --git a/quartz-manager-frontend/src/app/services/progress.rx-websocket.service.spec.ts b/quartz-manager-frontend/src/app/services/progress.rx-websocket.service.spec.ts new file mode 100644 index 0000000..cf32c29 --- /dev/null +++ b/quartz-manager-frontend/src/app/services/progress.rx-websocket.service.spec.ts @@ -0,0 +1,39 @@ +import { TestBed } from '@angular/core/testing'; +import {RxStomp} from '@stomp/rx-stomp'; +import {jest} from '@jest/globals'; + +import { ProgressRxWebsocketService } from './progress.rx-websocket.service'; +import {ApiService} from './api.service'; + +describe('ProgressRxWebsocketService', () => { + let service: ProgressRxWebsocketService; + let configureSpy; + let activateSpy; + + beforeEach(() => { + configureSpy = jest.spyOn(RxStomp.prototype, 'configure'); + activateSpy = jest.spyOn(RxStomp.prototype, 'activate').mockImplementation(() => undefined); + + TestBed.configureTestingModule({ + providers: [ + {provide: ApiService, useValue: {getToken: () => 'test-token'}} + ] + }); + service = TestBed.inject(ProgressRxWebsocketService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should configure rx-stomp with the progress websocket endpoint', () => { + expect(configureSpy).toHaveBeenCalled(); + expect(activateSpy).toHaveBeenCalled(); + + const config = configureSpy.mock.calls[configureSpy.mock.calls.length - 1][0]; + expect(config.heartbeatIncoming).toEqual(0); + expect(config.heartbeatOutgoing).toEqual(20000); + expect(config.reconnectDelay).toEqual(200); + expect(config.webSocketFactory.toString()).toContain('/progress?access_token='); + }); +}); diff --git a/quartz-manager-frontend/src/app/services/user.service.ts b/quartz-manager-frontend/src/app/services/user.service.ts index ae0bf61..224953f 100644 --- a/quartz-manager-frontend/src/app/services/user.service.ts +++ b/quartz-manager-frontend/src/app/services/user.service.ts @@ -35,7 +35,7 @@ export class UserService { this.currentUser = user; this.router.initialNavigation(); }, err => { - console.log(`error retrieving current user due to ` + JSON.stringify(err)); + console.log('error retrieving current user due to ' + JSON.stringify(err)); const httpErrorResponse = err as HttpErrorResponse; if (httpErrorResponse.status === 404) { this.isAnAnonymousUser = true; diff --git a/quartz-manager-frontend/src/app/views/manager/manager.component.html b/quartz-manager-frontend/src/app/views/manager/manager.component.html index cc71362..1428332 100644 --- a/quartz-manager-frontend/src/app/views/manager/manager.component.html +++ b/quartz-manager-frontend/src/app/views/manager/manager.component.html @@ -19,10 +19,11 @@
- - + +
diff --git a/quartz-manager-frontend/src/app/views/manager/manager.component.ts b/quartz-manager-frontend/src/app/views/manager/manager.component.ts index 8c010dd..20a9a7d 100644 --- a/quartz-manager-frontend/src/app/views/manager/manager.component.ts +++ b/quartz-manager-frontend/src/app/views/manager/manager.component.ts @@ -1,8 +1,4 @@ import {Component, OnInit, ViewChild} from '@angular/core'; -import { - ConfigService, - UserService -} from '../../services'; import {SimpleTrigger} from '../../model/simple-trigger.model'; import {TriggerKey} from '../../model/triggerKey.model'; import {SimpleTriggerConfigComponent} from '../../components/simple-trigger-config'; @@ -33,15 +29,24 @@ export class ManagerComponent implements OnInit { onNewTriggerRequested() { this.selectedTriggerKey = null; - // this.triggerConfigComponent.openTriggerForm(); + this.newTriggerFormOpened = true; + if (this.triggerConfigComponent) { + this.triggerConfigComponent.openNewTriggerForm(); + } } onNewTriggerCreated(newTrigger: SimpleTrigger) { this.triggerListComponent.onNewTrigger(newTrigger); + this.newTriggerFormOpened = false; } setSelectedTrigger(triggerKey: TriggerKey) { this.selectedTriggerKey = triggerKey; + this.newTriggerFormOpened = false; + } + + setNewTriggerFormOpened(opened: boolean) { + this.newTriggerFormOpened = opened; } } diff --git a/quartz-manager-frontend/src/polyfills.ts b/quartz-manager-frontend/src/polyfills.ts index ecc7cec..95ab387 100644 --- a/quartz-manager-frontend/src/polyfills.ts +++ b/quartz-manager-frontend/src/polyfills.ts @@ -14,7 +14,7 @@ * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html */ -/*************************************************************************************************** +/** ************************************************************************************************* * BROWSER POLYFILLS */ @@ -50,7 +50,7 @@ import 'core-js/es6/reflect'; -/*************************************************************************************************** +/** ************************************************************************************************* * Zone JS is required by Angular itself. */ import 'zone.js/dist/zone'; // Included with Angular CLI. @@ -59,7 +59,7 @@ import 'zone.js/dist/zone'; // Included with Angular CLI. -/*************************************************************************************************** +/** ************************************************************************************************* * APPLICATION IMPORTS */ @@ -69,7 +69,7 @@ import 'zone.js/dist/zone'; // Included with Angular CLI. */ // import 'intl'; // Run `npm install --save intl`. -/*************************************************************************************************** +/** ************************************************************************************************* * MATERIAL 2 */ import 'hammerjs/hammer'; diff --git a/quartz-manager-frontend/src/typings.d.ts b/quartz-manager-frontend/src/typings.d.ts index ef5c7bd..388fb92 100644 --- a/quartz-manager-frontend/src/typings.d.ts +++ b/quartz-manager-frontend/src/typings.d.ts @@ -1,5 +1,5 @@ /* SystemJS module definition */ -declare var module: NodeModule; +declare let module: NodeModule; interface NodeModule { id: string; } diff --git a/quartz-manager-parent/.gitignore b/quartz-manager-parent/.gitignore index a080dd8..a3232ff 100644 --- a/quartz-manager-parent/.gitignore +++ b/quartz-manager-parent/.gitignore @@ -4,3 +4,4 @@ .classpath .project .idea +/**/*.iml diff --git a/quartz-manager-parent/pom.xml b/quartz-manager-parent/pom.xml index a9e3bea..8794fae 100644 --- a/quartz-manager-parent/pom.xml +++ b/quartz-manager-parent/pom.xml @@ -50,6 +50,7 @@ 1.6.7 2.5.3 3.0.1 + 3.11.0.3922 fabioformosa https://sonarcloud.io @@ -133,6 +134,11 @@ maven-failsafe-plugin ${maven-failsafe-plugin.version} + + org.sonarsource.scanner.maven + sonar-maven-plugin + ${sonar-maven-plugin.version} + org.jacoco jacoco-maven-plugin diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/services/SimpleTriggerServiceIntegrationTest.java b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/services/SimpleTriggerServiceIntegrationTest.java index f26e829..74c633d 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/services/SimpleTriggerServiceIntegrationTest.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/services/SimpleTriggerServiceIntegrationTest.java @@ -18,13 +18,17 @@ import java.util.Date; @SpringBootTest class SimpleTriggerServiceIntegrationTest { + private static final String SAMPLE_JOB_CLASS = "it.fabioformosa.quartzmanager.api.jobs.SampleJob"; + private static final String SAMPLE_EXTRA_JOB_CLASS = "it.fabioformosa.samplepackage.SampleExtraJob"; + private static final String FIRST_TRIGGER_SUFFIX = "A"; + private static final String SECOND_TRIGGER_SUFFIX = "B"; + @Autowired private SimpleTriggerService simpleTriggerService; @Test void givenASimpleTriggerCommandDTOWithAllData_whenANewSimpleTriggerIsScheduled_thenShouldGetATriggertDTO() throws SchedulerException, ClassNotFoundException { String simpleTriggerTestName = "simpleTriggerWithAllData"; - String jobClass = "it.fabioformosa.quartzmanager.api.jobs.SampleJob"; Date startDate = new Date(); Date endDate = DateUtils.addHoursToNow(5); int repeatCount = 3; @@ -41,7 +45,7 @@ class SimpleTriggerServiceIntegrationTest { .repeatCount(repeatCount) .repeatInterval(repeatInterval) .misfireInstruction(misfireInstructionFireNow) - .jobClass(jobClass) + .jobClass(SAMPLE_JOB_CLASS) .build()) .build(); SimpleTriggerDTO simpleTriggerDTO = simpleTriggerService.scheduleSimpleTrigger(simpleTriggerCommand); @@ -61,12 +65,11 @@ class SimpleTriggerServiceIntegrationTest { @Test void givenASimpleTriggerCommandDTOWithMissingOptionalField_whenANewSimpleTriggerIsScheduled_thenShouldGetATriggertDTO() throws SchedulerException, ClassNotFoundException { String simpleTriggerTestName = "simpleTriggerWithoutOptionalData"; - String jobClass = "it.fabioformosa.quartzmanager.api.jobs.SampleJob"; SimpleTriggerCommandDTO simpleTriggerCommand = SimpleTriggerCommandDTO.builder() .triggerName(simpleTriggerTestName) .simpleTriggerInputDTO(SimpleTriggerInputDTO.builder() - .jobClass(jobClass) + .jobClass(SAMPLE_JOB_CLASS) .build()) .build(); SimpleTriggerDTO simpleTriggerDTO = simpleTriggerService.scheduleSimpleTrigger(simpleTriggerCommand); @@ -81,4 +84,49 @@ class SimpleTriggerServiceIntegrationTest { Assertions.assertThat(simpleTriggerDTO.getRepeatInterval()).isZero(); } + @Test + void givenTwoSimpleTriggerCommandDTOsForTheSameJob_whenScheduled_thenShouldCreateTwoTriggers() throws SchedulerException, ClassNotFoundException { + String triggerNamePrefix = "sameJobTrigger" + System.nanoTime(); + + SimpleTriggerDTO firstTrigger = simpleTriggerService.scheduleSimpleTrigger( + buildSimpleTriggerCommand(triggerNamePrefix + FIRST_TRIGGER_SUFFIX, SAMPLE_JOB_CLASS) + ); + SimpleTriggerDTO secondTrigger = simpleTriggerService.scheduleSimpleTrigger( + buildSimpleTriggerCommand(triggerNamePrefix + SECOND_TRIGGER_SUFFIX, SAMPLE_JOB_CLASS) + ); + + Assertions.assertThat(firstTrigger.getTriggerKeyDTO().getName()).isEqualTo(triggerNamePrefix + FIRST_TRIGGER_SUFFIX); + Assertions.assertThat(secondTrigger.getTriggerKeyDTO().getName()).isEqualTo(triggerNamePrefix + SECOND_TRIGGER_SUFFIX); + Assertions.assertThat(firstTrigger.getJobDetailDTO().getJobClassName()).isEqualTo(SAMPLE_JOB_CLASS); + Assertions.assertThat(secondTrigger.getJobDetailDTO().getJobClassName()).isEqualTo(SAMPLE_JOB_CLASS); + Assertions.assertThat(firstTrigger.getJobKeyDTO().getName()).isNotEqualTo(secondTrigger.getJobKeyDTO().getName()); + } + + @Test + void givenTwoSimpleTriggerCommandDTOsForDifferentJobs_whenScheduled_thenShouldCreateTwoTriggers() throws SchedulerException, ClassNotFoundException { + String triggerNamePrefix = "differentJobTrigger" + System.nanoTime(); + + SimpleTriggerDTO firstTrigger = simpleTriggerService.scheduleSimpleTrigger( + buildSimpleTriggerCommand(triggerNamePrefix + FIRST_TRIGGER_SUFFIX, SAMPLE_JOB_CLASS) + ); + SimpleTriggerDTO secondTrigger = simpleTriggerService.scheduleSimpleTrigger( + buildSimpleTriggerCommand(triggerNamePrefix + SECOND_TRIGGER_SUFFIX, SAMPLE_EXTRA_JOB_CLASS) + ); + + Assertions.assertThat(firstTrigger.getTriggerKeyDTO().getName()).isEqualTo(triggerNamePrefix + FIRST_TRIGGER_SUFFIX); + Assertions.assertThat(secondTrigger.getTriggerKeyDTO().getName()).isEqualTo(triggerNamePrefix + SECOND_TRIGGER_SUFFIX); + Assertions.assertThat(firstTrigger.getJobDetailDTO().getJobClassName()).isEqualTo(SAMPLE_JOB_CLASS); + Assertions.assertThat(secondTrigger.getJobDetailDTO().getJobClassName()).isEqualTo(SAMPLE_EXTRA_JOB_CLASS); + } + + private static SimpleTriggerCommandDTO buildSimpleTriggerCommand(String triggerName, String jobClass) { + return SimpleTriggerCommandDTO.builder() + .triggerName(triggerName) + .simpleTriggerInputDTO(SimpleTriggerInputDTO.builder() + .jobClass(jobClass) + .startDate(DateUtils.addHoursToNow(1)) + .build()) + .build(); + } + } diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/websockets/WebSocketNotifierTest.java b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/websockets/WebSocketNotifierTest.java new file mode 100644 index 0000000..94b38ad --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/websockets/WebSocketNotifierTest.java @@ -0,0 +1,48 @@ +package it.fabioformosa.quartzmanager.api.websockets; + +import it.fabioformosa.quartzmanager.api.dto.TriggerFiredBundleDTO; +import it.fabioformosa.quartzmanager.api.jobs.entities.LogRecord; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.springframework.messaging.simp.SimpMessageSendingOperations; + +import static org.mockito.MockitoAnnotations.openMocks; + +class WebSocketNotifierTest { + + @InjectMocks + private WebSocketLogsNotifier webSocketLogsNotifier; + + @InjectMocks + private WebSocketProgressNotifier webSocketProgressNotifier; + + @Mock + private SimpMessageSendingOperations messagingTemplate; + + @BeforeEach + void setUp() { + openMocks(this); + } + + @Test + void givenATriggerName_whenALogIsSent_thenShouldSendItToTheTriggerLogsTopic() { + LogRecord logRecord = new LogRecord(LogRecord.LogType.INFO, "Hello!"); + + webSocketLogsNotifier.send("trigger-1", logRecord); + + Mockito.verify(messagingTemplate).convertAndSend("/topic/logs/trigger-1", logRecord); + } + + @Test + void givenATriggerName_whenProgressIsSent_thenShouldSendItToTheTriggerProgressTopic() { + TriggerFiredBundleDTO triggerFiredBundleDTO = new TriggerFiredBundleDTO(); + + webSocketProgressNotifier.send("trigger-1", triggerFiredBundleDTO); + + Mockito.verify(messagingTemplate).convertAndSend("/topic/progress/trigger-1", triggerFiredBundleDTO); + } + +}