NIFI-15040: Improving test coverage for the Connection components in the Flow Designer. (#10371)
This closes #10371
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/create-connection/create-connection.component.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/create-connection/create-connection.component.html
index dda8f98..de684d2 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/create-connection/create-connection.component.html
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/create-connection/create-connection.component.html
@@ -15,11 +15,11 @@
~ limitations under the License.
-->
-<h2 mat-dialog-title>Create Connection</h2>
+<h2 mat-dialog-title data-qa="dialog-title">Create Connection</h2>
@if (breadcrumbs$ | async; as breadcrumbs) {
- <form class="create-connection-form" [formGroup]="createConnectionForm">
+ <form class="create-connection-form" [formGroup]="createConnectionForm" data-qa="create-connection-form">
<context-error-banner [context]="ErrorContextKey.CONNECTION"></context-error-banner>
- <mat-tab-group>
+ <mat-tab-group data-qa="connection-tabs">
<mat-tab label="Details">
<mat-dialog-content>
<div class="dialog-tab-content flex">
@@ -223,13 +223,14 @@
</mat-tab>
</mat-tab-group>
@if ({ value: (saving$ | async)! }; as saving) {
- <mat-dialog-actions align="end">
- <button mat-button mat-dialog-close>Cancel</button>
+ <mat-dialog-actions align="end" data-qa="dialog-actions">
+ <button mat-button mat-dialog-close data-qa="cancel-button">Cancel</button>
<button
[disabled]="createConnectionForm.invalid || saving.value || createConnectionForm.pending"
type="button"
(click)="createConnection(breadcrumbs.id)"
- mat-flat-button>
+ mat-flat-button
+ data-qa="add-button">
<span *nifiSpinner="saving.value">Add</span>
</button>
</mat-dialog-actions>
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/create-connection/create-connection.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/create-connection/create-connection.component.spec.ts
index 539757a..de4e972 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/create-connection/create-connection.component.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/create-connection/create-connection.component.spec.ts
@@ -15,17 +15,16 @@
* limitations under the License.
*/
-import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { TestBed } from '@angular/core/testing';
import { CreateConnection } from './create-connection.component';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
-import { provideMockStore } from '@ngrx/store/testing';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../../../../state/flow/flow.reducer';
import { CreateConnectionDialogRequest } from '../../../../../state/flow';
import { ComponentType } from '@nifi/shared';
import { DocumentedType } from '../../../../../../../state/shared';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
-import { of } from 'rxjs';
import { ClusterConnectionService } from '../../../../../../../service/cluster-connection.service';
import { initialState as initialErrorState } from '../../../../../../../state/error/error.reducer';
import { errorFeatureKey } from '../../../../../../../state/error';
@@ -33,350 +32,136 @@
import { currentUserFeatureKey } from '../../../../../../../state/current-user';
import { canvasFeatureKey } from '../../../../../state';
import { flowFeatureKey } from '../../../../../state/flow';
+import { selectBreadcrumbs, selectSaving } from '../../../../../state/flow/flow.selectors';
+import { selectPrioritizerTypes } from '../../../../../../../state/extension-types/extension-types.selectors';
+import { createConnection } from '../../../../../state/flow/flow.actions';
describe('CreateConnection', () => {
- let component: CreateConnection;
- let fixture: ComponentFixture<CreateConnection>;
-
- const data: CreateConnectionDialogRequest = {
- request: {
- source: {
- id: 'a67bf99d-018b-1000-611d-2993eb2f64b8',
- componentType: ComponentType.InputPort,
- entity: {
- revision: {
- version: 0
- },
- id: 'a67bf99d-018b-1000-611d-2993eb2f64b8',
- uri: 'https://localhost:4200/nifi-api/input-ports/a67bf99d-018b-1000-611d-2993eb2f64b8',
- position: {
- x: 1160,
- y: -320
- },
- permissions: {
- canRead: true,
- canWrite: true
- },
- bulletins: [],
- component: {
- id: 'a67bf99d-018b-1000-611d-2993eb2f64b8',
- versionedComponentId: '77458ab4-8e53-3855-a682-c787a2705b9d',
- parentGroupId: '95a4b210-018b-1000-772a-5a9ebfa03287',
- position: {
- x: 1160,
- y: -320
- },
- name: 'in',
- state: 'STOPPED',
- type: 'INPUT_PORT',
- transmitting: false,
- concurrentlySchedulableTaskCount: 1,
- allowRemoteAccess: true,
- portFunction: 'STANDARD'
- },
- status: {
- id: 'a67bf99d-018b-1000-611d-2993eb2f64b8',
- groupId: '95a4b210-018b-1000-772a-5a9ebfa03287',
- name: 'in',
- transmitting: false,
- runStatus: 'Stopped',
- statsLastRefreshed: '14:03:53 EST',
- aggregateSnapshot: {
- id: 'a67bf99d-018b-1000-611d-2993eb2f64b8',
- groupId: '95a4b210-018b-1000-772a-5a9ebfa03287',
- name: 'in',
- activeThreadCount: 0,
- flowFilesIn: 0,
- bytesIn: 0,
- input: '0 (0 bytes)',
- flowFilesOut: 0,
- bytesOut: 0,
- output: '0 (0 bytes)',
- runStatus: 'Stopped'
- }
- },
- portType: 'INPUT_PORT',
- operatePermissions: {
- canRead: false,
- canWrite: false
- },
- allowRemoteAccess: true,
- type: 'InputPort',
- dimensions: {
- width: 240,
- height: 80
- }
+ // Mock data factories
+ function createMockInputPort(id: string = 'input-port-id'): any {
+ return {
+ id,
+ componentType: ComponentType.InputPort,
+ entity: {
+ id,
+ permissions: {
+ canRead: true,
+ canWrite: true
+ },
+ component: {
+ id,
+ name: 'Input Port',
+ parentGroupId: 'group-id'
}
- },
- destination: {
- id: 'ca0a0504-018b-1000-5917-2b063e8946b9',
- componentType: ComponentType.Processor,
- entity: {
- revision: {
- clientId: 'd8e8a955-018b-1000-915e-a59d0e7933ef',
- version: 6
- },
- id: 'ca0a0504-018b-1000-5917-2b063e8946b9',
- uri: 'https://localhost:4200/nifi-api/processors/ca0a0504-018b-1000-5917-2b063e8946b9',
- position: {
- x: 144,
- y: -280
- },
- permissions: {
- canRead: true,
- canWrite: true
- },
- bulletins: [],
- component: {
- id: 'ca0a0504-018b-1000-5917-2b063e8946b9',
- versionedComponentId: 'e499c564-caa6-3c53-ad8b-371745868b27',
- parentGroupId: '95a4b210-018b-1000-772a-5a9ebfa03287',
- position: {
- x: 144,
- y: -280
- },
- name: 'UpdateAttribute',
- type: 'org.apache.nifi.processors.attributes.UpdateAttribute',
- bundle: {
- group: 'org.apache.nifi',
- artifact: 'nifi-update-attribute-nar',
- version: '2.0.0-SNAPSHOT'
- },
- state: 'STOPPED',
- style: {
- 'background-color': '#966969'
- },
- relationships: [
- {
- name: 'success',
- description: 'All successful FlowFiles are routed to this relationship',
- autoTerminate: false,
- retry: false
- }
- ],
- supportsParallelProcessing: true,
- supportsBatching: true,
- supportsSensitiveDynamicProperties: false,
- persistsState: true,
- restricted: false,
- deprecated: false,
- executionNodeRestricted: false,
- multipleVersionsAvailable: false,
- inputRequirement: 'INPUT_REQUIRED',
- config: {
- properties: {
- 'Delete Attributes Expression': null,
- 'Store State': 'Do not store state',
- 'Stateful Variables Initial Value': null,
- 'canonical-value-lookup-cache-size': '100',
- asdf: 'qwer'
- },
- descriptors: {
- 'Delete Attributes Expression': {
- name: 'Delete Attributes Expression',
- displayName: 'Delete Attributes Expression',
- description:
- 'Regular expression for attributes to be deleted from FlowFiles. Existing attributes that match will be deleted regardless of whether they are updated by this processor.',
- required: false,
- sensitive: false,
- dynamic: false,
- supportsEl: true,
- expressionLanguageScope: 'Environment variables and FlowFile Attributes',
- dependencies: []
- },
- 'Store State': {
- name: 'Store State',
- displayName: 'Store State',
- description:
- "Select whether or not state will be stored. Selecting 'Stateless' will offer the default functionality of purely updating the attributes on a FlowFile in a stateless manner. Selecting a stateful option will not only store the attributes on the FlowFile but also in the Processors state. See the 'Stateful Usage' topic of the 'Additional Details' section of this processor's documentation for more information",
- defaultValue: 'Do not store state',
- allowableValues: [
- {
- allowableValue: {
- displayName: 'Do not store state',
- value: 'Do not store state'
- },
- canRead: true
- },
- {
- allowableValue: {
- displayName: 'Store state locally',
- value: 'Store state locally'
- },
- canRead: true
- }
- ],
- required: true,
- sensitive: false,
- dynamic: false,
- supportsEl: false,
- expressionLanguageScope: 'Not Supported',
- dependencies: []
- },
- 'Stateful Variables Initial Value': {
- name: 'Stateful Variables Initial Value',
- displayName: 'Stateful Variables Initial Value',
- description:
- 'If using state to set/reference variables then this value is used to set the initial value of the stateful variable. This will only be used in the @OnScheduled method when state does not contain a value for the variable. This is required if running statefully but can be empty if needed.',
- required: false,
- sensitive: false,
- dynamic: false,
- supportsEl: false,
- expressionLanguageScope: 'Not Supported',
- dependencies: []
- },
- 'canonical-value-lookup-cache-size': {
- name: 'canonical-value-lookup-cache-size',
- displayName: 'Cache Value Lookup Cache Size',
- description:
- 'Specifies how many canonical lookup values should be stored in the cache',
- defaultValue: '100',
- required: true,
- sensitive: false,
- dynamic: false,
- supportsEl: false,
- expressionLanguageScope: 'Not Supported',
- dependencies: []
- },
- asdf: {
- name: 'asdf',
- displayName: 'asdf',
- description: '',
- required: false,
- sensitive: false,
- dynamic: true,
- supportsEl: true,
- expressionLanguageScope: 'Environment variables and FlowFile Attributes',
- dependencies: []
- }
- },
- schedulingPeriod: '10 sec',
- schedulingStrategy: 'TIMER_DRIVEN',
- executionNode: 'ALL',
- penaltyDuration: '30 sec',
- yieldDuration: '1 sec',
- bulletinLevel: 'WARN',
- runDurationMillis: 25,
- concurrentlySchedulableTaskCount: 1,
- autoTerminatedRelationships: [],
- comments: '',
- customUiUrl: '/nifi-update-attribute-ui-2.0.0-SNAPSHOT',
- lossTolerant: false,
- defaultConcurrentTasks: {
- TIMER_DRIVEN: '1',
- CRON_DRIVEN: '1'
- },
- defaultSchedulingPeriod: {
- TIMER_DRIVEN: '0 sec',
- CRON_DRIVEN: '* * * * * ?'
- },
- retryCount: 10,
- retriedRelationships: [],
- backoffMechanism: 'PENALIZE_FLOWFILE',
- maxBackoffPeriod: '10 mins'
- },
- validationStatus: 'VALID',
- extensionMissing: false
- },
- inputRequirement: 'INPUT_REQUIRED',
- status: {
- groupId: '95a4b210-018b-1000-772a-5a9ebfa03287',
- id: 'ca0a0504-018b-1000-5917-2b063e8946b9',
- name: 'UpdateAttribute',
- runStatus: 'Stopped',
- statsLastRefreshed: '14:03:53 EST',
- aggregateSnapshot: {
- id: 'ca0a0504-018b-1000-5917-2b063e8946b9',
- groupId: '95a4b210-018b-1000-772a-5a9ebfa03287',
- name: 'UpdateAttribute',
- type: 'UpdateAttribute',
- runStatus: 'Stopped',
- executionNode: 'ALL',
- bytesRead: 0,
- bytesWritten: 0,
- read: '0 bytes',
- written: '0 bytes',
- flowFilesIn: 0,
- bytesIn: 0,
- input: '0 (0 bytes)',
- flowFilesOut: 0,
- bytesOut: 0,
- output: '0 (0 bytes)',
- taskCount: 0,
- tasksDurationNanos: 0,
- tasks: '0',
- tasksDuration: '00:00:00.000',
- activeThreadCount: 0,
- terminatedThreadCount: 0
- }
- },
- operatePermissions: {
- canRead: false,
- canWrite: false
- },
- type: 'Processor',
- dimensions: {
- width: 352,
- height: 128
+ }
+ };
+ }
+
+ function createMockProcessor(id: string = 'processor-id'): any {
+ return {
+ id,
+ componentType: ComponentType.Processor,
+ entity: {
+ id,
+ permissions: {
+ canRead: true,
+ canWrite: true
+ },
+ component: {
+ id,
+ name: 'Test Processor',
+ parentGroupId: 'group-id',
+ relationships: [{ name: 'success', description: 'Success relationship', autoTerminate: false }]
+ }
+ }
+ };
+ }
+
+ function createMockProcessGroup(id: string = 'process-group-id'): any {
+ return {
+ id,
+ componentType: ComponentType.ProcessGroup,
+ entity: {
+ id,
+ permissions: { canRead: true, canWrite: true },
+ component: {
+ id,
+ name: 'Test Process Group',
+ parentGroupId: 'group-id'
+ }
+ }
+ };
+ }
+
+ function createMockRemoteProcessGroup(id: string = 'remote-group-id'): any {
+ return {
+ id,
+ componentType: ComponentType.RemoteProcessGroup,
+ entity: {
+ id,
+ permissions: {
+ canRead: true,
+ canWrite: true
+ },
+ component: {
+ id,
+ name: 'Remote Process Group',
+ parentGroupId: 'group-id',
+ contents: {
+ outputPorts: [{ id: 'output-1', name: 'Output 1' }],
+ inputPorts: [{ id: 'input-1', name: 'Input 1' }]
}
}
}
- },
- defaults: {
- flowfileExpiration: '0 sec',
- objectThreshold: 10000,
- dataSizeThreshold: '1 GB'
- }
- };
+ };
+ }
- const parameterContexts: DocumentedType[] = [
- {
- type: 'org.apache.nifi.prioritizer.FirstInFirstOutPrioritizer',
- bundle: {
- group: 'org.apache.nifi',
- artifact: 'nifi-framework-nar',
- version: '2.0.0-SNAPSHOT'
- },
- restricted: false,
- tags: []
- },
- {
- type: 'org.apache.nifi.prioritizer.NewestFlowFileFirstPrioritizer',
- bundle: {
- group: 'org.apache.nifi',
- artifact: 'nifi-framework-nar',
- version: '2.0.0-SNAPSHOT'
- },
- restricted: false,
- tags: []
- },
- {
- type: 'org.apache.nifi.prioritizer.OldestFlowFileFirstPrioritizer',
- bundle: {
- group: 'org.apache.nifi',
- artifact: 'nifi-framework-nar',
- version: '2.0.0-SNAPSHOT'
- },
- restricted: false,
- tags: []
- },
- {
- type: 'org.apache.nifi.prioritizer.PriorityAttributePrioritizer',
- bundle: {
- group: 'org.apache.nifi',
- artifact: 'nifi-framework-nar',
- version: '2.0.0-SNAPSHOT'
- },
- restricted: false,
- tags: []
- }
- ];
+ function createMockBreadcrumb() {
+ return {
+ id: 'root',
+ permissions: { canRead: true, canWrite: true },
+ versionedFlowState: 'UP_TO_DATE',
+ breadcrumb: { id: 'root', name: 'Root Group' }
+ };
+ }
- beforeEach(() => {
- TestBed.configureTestingModule({
+ function createMockPrioritizers(): DocumentedType[] {
+ return [
+ {
+ type: 'org.apache.nifi.prioritizer.FirstInFirstOutPrioritizer',
+ bundle: { group: 'org.apache.nifi', artifact: 'nifi-framework-nar', version: '2.0.0-SNAPSHOT' },
+ restricted: false,
+ tags: []
+ }
+ ];
+ }
+
+ // Setup function for component configuration
+ async function setup(
+ options: {
+ source?: any;
+ destination?: any;
+ } = {}
+ ) {
+ const testSource = options.source || createMockInputPort();
+ const testDestination = options.destination || createMockProcessor();
+
+ const testDialogData: CreateConnectionDialogRequest = {
+ request: {
+ source: testSource,
+ destination: testDestination
+ },
+ defaults: {
+ flowfileExpiration: '0 sec',
+ objectThreshold: 10000,
+ dataSizeThreshold: '1 GB'
+ }
+ };
+
+ await TestBed.configureTestingModule({
imports: [CreateConnection, NoopAnimationsModule],
providers: [
- { provide: MAT_DIALOG_DATA, useValue: data },
+ { provide: MAT_DIALOG_DATA, useValue: testDialogData },
provideMockStore({
initialState: {
[errorFeatureKey]: initialErrorState,
@@ -389,19 +174,210 @@
{
provide: ClusterConnectionService,
useValue: {
- isDisconnectionAcknowledged: jest.fn()
+ isDisconnectionAcknowledged: jest.fn().mockReturnValue(false)
}
},
{ provide: MatDialogRef, useValue: null }
]
- });
- fixture = TestBed.createComponent(CreateConnection);
- component = fixture.componentInstance;
- component.availablePrioritizers$ = of(parameterContexts);
+ }).compileComponents();
+
+ const store = TestBed.inject(MockStore);
+
+ // Setup required selectors
+ store.overrideSelector(selectBreadcrumbs, createMockBreadcrumb());
+ store.overrideSelector(selectSaving, false);
+ store.overrideSelector(selectPrioritizerTypes, createMockPrioritizers());
+
+ const fixture = TestBed.createComponent(CreateConnection);
+ const component = fixture.componentInstance;
+
fixture.detectChanges();
+
+ return { component, fixture, store };
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks();
});
- it('should create', () => {
- expect(component).toBeTruthy();
+ describe('Component initialization', () => {
+ it('should create', async () => {
+ const { component } = await setup();
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize with InputPort source and Processor destination', async () => {
+ const { component } = await setup();
+ expect(component.source).toBeDefined();
+ expect(component.destination).toBeDefined();
+ expect(component.source.componentType).toBe(ComponentType.InputPort);
+ expect(component.destination.componentType).toBe(ComponentType.Processor);
+ });
+
+ it('should initialize form with default values', async () => {
+ const { component } = await setup();
+ expect(component.createConnectionForm.get('flowFileExpiration')?.value).toBe('0 sec');
+ expect(component.createConnectionForm.get('backPressureObjectThreshold')?.value).toBe(10000);
+ expect(component.createConnectionForm.get('backPressureDataSizeThreshold')?.value).toBe('1 GB');
+ });
+ });
+
+ describe('Source component types', () => {
+ it('should handle Processor source type', async () => {
+ const source = createMockProcessor('source-processor');
+ const { component } = await setup({ source });
+
+ expect(component.source.componentType).toBe(ComponentType.Processor);
+ expect(component.createConnectionForm.get('relationships')).toBeTruthy();
+ });
+
+ it('should handle ProcessGroup source type', async () => {
+ const source = createMockProcessGroup('source-group');
+ const { component } = await setup({ source });
+
+ expect(component.source.componentType).toBe(ComponentType.ProcessGroup);
+ });
+
+ it('should handle RemoteProcessGroup source type', async () => {
+ const source = createMockRemoteProcessGroup('source-remote');
+ const { component } = await setup({ source });
+
+ expect(component.source.componentType).toBe(ComponentType.RemoteProcessGroup);
+ });
+ });
+
+ describe('Destination component types', () => {
+ it('should handle Processor destination type', async () => {
+ const destination = createMockProcessor('dest-processor');
+ const { component } = await setup({ destination });
+
+ expect(component.destination.componentType).toBe(ComponentType.Processor);
+ });
+
+ it('should handle ProcessGroup destination type', async () => {
+ const destination = createMockProcessGroup('dest-group');
+ const { component } = await setup({ destination });
+
+ expect(component.destination.componentType).toBe(ComponentType.ProcessGroup);
+ });
+
+ it('should handle RemoteProcessGroup destination type', async () => {
+ const destination = createMockRemoteProcessGroup('dest-remote');
+ const { component } = await setup({ destination });
+
+ expect(component.destination.componentType).toBe(ComponentType.RemoteProcessGroup);
+ });
+ });
+
+ describe('Load balance strategy logic', () => {
+ it('should handle DO_NOT_LOAD_BALANCE by default', async () => {
+ const { component } = await setup();
+
+ expect(component.createConnectionForm.get('loadBalanceStrategy')?.value).toBe('DO_NOT_LOAD_BALANCE');
+ expect(component.loadBalancePartitionAttributeRequired).toBe(false);
+ expect(component.loadBalanceCompressionRequired).toBe(false);
+ });
+
+ it('should handle PARTITION_BY_ATTRIBUTE strategy change', async () => {
+ const { component } = await setup();
+
+ component.loadBalanceChanged('PARTITION_BY_ATTRIBUTE');
+
+ expect(component.loadBalancePartitionAttributeRequired).toBe(true);
+ expect(component.loadBalanceCompressionRequired).toBe(true);
+ expect(component.createConnectionForm.get('partitionAttribute')).toBeTruthy();
+ expect(component.createConnectionForm.get('compression')).toBeTruthy();
+ });
+
+ it('should handle ROUND_ROBIN strategy change', async () => {
+ const { component } = await setup();
+
+ component.loadBalanceChanged('ROUND_ROBIN');
+
+ expect(component.loadBalancePartitionAttributeRequired).toBe(false);
+ expect(component.loadBalanceCompressionRequired).toBe(true);
+ expect(component.createConnectionForm.get('compression')).toBeTruthy();
+ });
+ });
+
+ describe('Create connection method', () => {
+ it('should dispatch createConnection action when createConnection is called', async () => {
+ const { component, store } = await setup();
+
+ const dispatchSpy = jest.spyOn(store, 'dispatch');
+
+ component.createConnection('root');
+
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ createConnection({
+ request: expect.objectContaining({
+ payload: expect.any(Object)
+ })
+ })
+ );
+ });
+
+ it('should include relationships for Processor source type', async () => {
+ const source = createMockProcessor('source-processor');
+ const { component, store, fixture } = await setup({ source });
+
+ // Set relationships
+ component.createConnectionForm.patchValue({ relationships: ['success'] });
+ fixture.detectChanges();
+
+ const dispatchSpy = jest.spyOn(store, 'dispatch');
+
+ component.createConnection('root');
+
+ expect(dispatchSpy).toHaveBeenCalled();
+ const dispatchCall = dispatchSpy.mock.calls[0][0] as any;
+ expect(dispatchCall.type).toBe('[Canvas] Create Connection');
+ expect(dispatchCall.request.payload.component.selectedRelationships).toEqual(['success']);
+ });
+ });
+
+ describe('Template logic', () => {
+ it('should display "Create Connection" title', async () => {
+ const { fixture } = await setup();
+
+ const dialogTitle = fixture.nativeElement.querySelector('[data-qa="dialog-title"]');
+ expect(dialogTitle).toBeTruthy();
+ expect(dialogTitle.textContent.trim()).toBe('Create Connection');
+ });
+
+ it('should display create connection form', async () => {
+ const { fixture } = await setup();
+
+ const form = fixture.nativeElement.querySelector('[data-qa="create-connection-form"]');
+ expect(form).toBeTruthy();
+
+ const tabs = fixture.nativeElement.querySelector('[data-qa="connection-tabs"]');
+ expect(tabs).toBeTruthy();
+ });
+
+ it('should display dialog actions with Cancel and Add buttons', async () => {
+ const { fixture } = await setup();
+
+ const dialogActions = fixture.nativeElement.querySelector('[data-qa="dialog-actions"]');
+ expect(dialogActions).toBeTruthy();
+
+ const cancelButton = fixture.nativeElement.querySelector('[data-qa="cancel-button"]');
+ expect(cancelButton).toBeTruthy();
+ expect(cancelButton.textContent.trim()).toBe('Cancel');
+
+ const addButton = fixture.nativeElement.querySelector('[data-qa="add-button"]');
+ expect(addButton).toBeTruthy();
+ });
+
+ it('should disable Add button when form is invalid', async () => {
+ const { component, fixture } = await setup();
+
+ // Make form invalid
+ component.createConnectionForm.setErrors({ invalid: true });
+ fixture.detectChanges();
+
+ const addButton = fixture.nativeElement.querySelector('[data-qa="add-button"]');
+ expect(addButton.disabled).toBe(true);
+ });
});
});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-funnel/destination-funnel.component.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-funnel/destination-funnel.component.html
index d25962c..ee49d72 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-funnel/destination-funnel.component.html
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-funnel/destination-funnel.component.html
@@ -17,12 +17,15 @@
<div class="flex flex-col gap-y-4">
<div class="flex flex-col mb-5">
- <div>To Funnel</div>
- <div class="tertiary-color font-medium">funnel</div>
+ <div data-qa="to-funnel-label">To Funnel</div>
+ <div class="tertiary-color font-medium" data-qa="funnel-text">funnel</div>
</div>
<div class="flex flex-col mb-5">
- <div>Within Group</div>
- <div [title]="groupName" class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium">
+ <div data-qa="within-group-label">Within Group</div>
+ <div
+ [title]="groupName"
+ class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium"
+ data-qa="group-name-display">
{{ groupName }}
</div>
</div>
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-funnel/destination-funnel.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-funnel/destination-funnel.component.spec.ts
index 214fa00..1141581 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-funnel/destination-funnel.component.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-funnel/destination-funnel.component.spec.ts
@@ -15,24 +15,135 @@
* limitations under the License.
*/
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-
+import { TestBed } from '@angular/core/testing';
import { DestinationFunnel } from './destination-funnel.component';
-describe('DestinationFunnel', () => {
- let component: DestinationFunnel;
- let fixture: ComponentFixture<DestinationFunnel>;
+describe('DestinationFunnel Component', () => {
+ // Setup function for component configuration
+ async function setup(
+ options: {
+ groupName?: string;
+ } = {}
+ ) {
+ await TestBed.configureTestingModule({
+ imports: [DestinationFunnel]
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(DestinationFunnel);
+ const component = fixture.componentInstance;
+
+ // Set input properties
+ if (options.groupName !== undefined) {
+ component.groupName = options.groupName;
+ }
+
+ fixture.detectChanges();
+
+ return { component, fixture };
+ }
beforeEach(() => {
- TestBed.configureTestingModule({
- imports: [DestinationFunnel]
- });
- fixture = TestBed.createComponent(DestinationFunnel);
- component = fixture.componentInstance;
- fixture.detectChanges();
+ jest.clearAllMocks();
});
- it('should create', () => {
- expect(component).toBeTruthy();
+ describe('Component initialization', () => {
+ it('should create', async () => {
+ const { component } = await setup();
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize with provided groupName', async () => {
+ const { component } = await setup({ groupName: 'Test Group' });
+ expect(component.groupName).toBe('Test Group');
+ });
+
+ it('should handle empty groupName', async () => {
+ const { component } = await setup({ groupName: '' });
+ expect(component.groupName).toBe('');
+ });
+ });
+
+ describe('Input property logic', () => {
+ it('should accept and store groupName input', async () => {
+ const { component } = await setup();
+
+ component.groupName = 'New Group Name';
+ expect(component.groupName).toBe('New Group Name');
+ });
+
+ it('should update groupName when input changes', async () => {
+ const { component } = await setup({ groupName: 'Initial Group' });
+
+ expect(component.groupName).toBe('Initial Group');
+
+ component.groupName = 'Updated Group';
+ expect(component.groupName).toBe('Updated Group');
+ });
+
+ it('should handle special characters in groupName', async () => {
+ const specialName = 'Group-Name_With!Special@Characters#123';
+ const { component } = await setup({ groupName: specialName });
+
+ expect(component.groupName).toBe(specialName);
+ });
+ });
+
+ describe('Template logic', () => {
+ it('should display "To Funnel" label', async () => {
+ const { fixture } = await setup();
+
+ const toFunnelLabel = fixture.nativeElement.querySelector('[data-qa="to-funnel-label"]');
+ expect(toFunnelLabel).toBeTruthy();
+ expect(toFunnelLabel.textContent.trim()).toBe('To Funnel');
+ });
+
+ it('should display static "funnel" text', async () => {
+ const { fixture } = await setup();
+
+ const funnelText = fixture.nativeElement.querySelector('[data-qa="funnel-text"]');
+ expect(funnelText).toBeTruthy();
+ expect(funnelText.textContent.trim()).toBe('funnel');
+ });
+
+ it('should display "Within Group" label', async () => {
+ const { fixture } = await setup();
+
+ const withinGroupLabel = fixture.nativeElement.querySelector('[data-qa="within-group-label"]');
+ expect(withinGroupLabel).toBeTruthy();
+ expect(withinGroupLabel.textContent.trim()).toBe('Within Group');
+ });
+
+ it('should display groupName in template', async () => {
+ const testGroupName = 'My Test Group';
+ const { fixture } = await setup({ groupName: testGroupName });
+
+ const groupNameDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupNameDisplay).toBeTruthy();
+ expect(groupNameDisplay.textContent.trim()).toBe(testGroupName);
+ });
+
+ it('should update groupName display when input changes', async () => {
+ const { fixture, component } = await setup({ groupName: 'Initial Group' });
+
+ // Verify initial display
+ let groupNameDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupNameDisplay.textContent.trim()).toBe('Initial Group');
+
+ // Change the input
+ component.groupName = 'Updated Group';
+ fixture.detectChanges();
+
+ // Verify updated display
+ groupNameDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupNameDisplay.textContent.trim()).toBe('Updated Group');
+ });
+
+ it('should set title attribute for groupName element', async () => {
+ const testGroupName = 'Group with Title';
+ const { fixture } = await setup({ groupName: testGroupName });
+
+ const groupNameDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupNameDisplay.getAttribute('title')).toBe(testGroupName);
+ });
});
});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-output-port/destination-output-port.component.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-output-port/destination-output-port.component.html
index 1332c85..a8f92e8 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-output-port/destination-output-port.component.html
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-output-port/destination-output-port.component.html
@@ -17,14 +17,20 @@
<div class="flex flex-col gap-y-4">
<div class="flex flex-col mb-5">
- <div>To Output</div>
- <div [title]="name" class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium">
+ <div data-qa="to-output-label">To Output</div>
+ <div
+ [title]="name"
+ class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium"
+ data-qa="output-port-name-display">
{{ name }}
</div>
</div>
<div class="flex flex-col mb-5">
- <div>Within Group</div>
- <div [title]="groupName" class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium">
+ <div data-qa="within-group-label">Within Group</div>
+ <div
+ [title]="groupName"
+ class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium"
+ data-qa="group-name-display">
{{ groupName }}
</div>
</div>
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-output-port/destination-output-port.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-output-port/destination-output-port.component.spec.ts
index 2216392..a251d1c 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-output-port/destination-output-port.component.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-output-port/destination-output-port.component.spec.ts
@@ -15,24 +15,195 @@
* limitations under the License.
*/
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-
+import { TestBed } from '@angular/core/testing';
import { DestinationOutputPort } from './destination-output-port.component';
-describe('DestinationOutputPort', () => {
- let component: DestinationOutputPort;
- let fixture: ComponentFixture<DestinationOutputPort>;
+describe('DestinationOutputPort Component', () => {
+ // Setup function for component configuration
+ async function setup(
+ options: {
+ outputPortName?: string;
+ groupName?: string;
+ } = {}
+ ) {
+ await TestBed.configureTestingModule({
+ imports: [DestinationOutputPort]
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(DestinationOutputPort);
+ const component = fixture.componentInstance;
+
+ // Set input properties
+ if (options.outputPortName !== undefined) {
+ component.outputPortName = options.outputPortName;
+ }
+ if (options.groupName !== undefined) {
+ component.groupName = options.groupName;
+ }
+
+ fixture.detectChanges();
+
+ return { component, fixture };
+ }
beforeEach(() => {
- TestBed.configureTestingModule({
- imports: [DestinationOutputPort]
- });
- fixture = TestBed.createComponent(DestinationOutputPort);
- component = fixture.componentInstance;
- fixture.detectChanges();
+ jest.clearAllMocks();
});
- it('should create', () => {
- expect(component).toBeTruthy();
+ describe('Component initialization', () => {
+ it('should create', async () => {
+ const { component } = await setup();
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize with provided values', async () => {
+ const { component } = await setup({
+ outputPortName: 'Test Output Port',
+ groupName: 'Test Group'
+ });
+
+ expect(component.name).toBe('Test Output Port');
+ expect(component.groupName).toBe('Test Group');
+ });
+
+ it('should handle empty values', async () => {
+ const { component } = await setup({
+ outputPortName: '',
+ groupName: ''
+ });
+
+ expect(component.name).toBe('');
+ expect(component.groupName).toBe('');
+ });
+ });
+
+ describe('Input property logic', () => {
+ it('should set name property when outputPortName is set', async () => {
+ const { component } = await setup();
+
+ component.outputPortName = 'New Output Port Name';
+ expect(component.name).toBe('New Output Port Name');
+ });
+
+ it('should update name when outputPortName changes', async () => {
+ const { component } = await setup({ outputPortName: 'Initial Name' });
+
+ expect(component.name).toBe('Initial Name');
+
+ component.outputPortName = 'Updated Name';
+ expect(component.name).toBe('Updated Name');
+ });
+
+ it('should accept and store groupName input', async () => {
+ const { component } = await setup();
+
+ component.groupName = 'New Group Name';
+ expect(component.groupName).toBe('New Group Name');
+ });
+
+ it('should update groupName when input changes', async () => {
+ const { component } = await setup({ groupName: 'Initial Group' });
+
+ expect(component.groupName).toBe('Initial Group');
+
+ component.groupName = 'Updated Group';
+ expect(component.groupName).toBe('Updated Group');
+ });
+
+ it('should handle special characters in names', async () => {
+ const specialOutputPortName = 'Output-Port_With!Special@Characters#123';
+ const specialGroupName = 'Group-Name_With!Special@Characters#123';
+ const { component } = await setup({
+ outputPortName: specialOutputPortName,
+ groupName: specialGroupName
+ });
+
+ expect(component.name).toBe(specialOutputPortName);
+ expect(component.groupName).toBe(specialGroupName);
+ });
+ });
+
+ describe('Template logic', () => {
+ it('should display "To Output" label', async () => {
+ const { fixture } = await setup();
+
+ const toOutputLabel = fixture.nativeElement.querySelector('[data-qa="to-output-label"]');
+ expect(toOutputLabel).toBeTruthy();
+ expect(toOutputLabel.textContent.trim()).toBe('To Output');
+ });
+
+ it('should display output port name in template', async () => {
+ const testOutputPortName = 'My Test Output Port';
+ const { fixture } = await setup({ outputPortName: testOutputPortName });
+
+ const outputPortNameDisplay = fixture.nativeElement.querySelector('[data-qa="output-port-name-display"]');
+ expect(outputPortNameDisplay).toBeTruthy();
+ expect(outputPortNameDisplay.textContent.trim()).toBe(testOutputPortName);
+ });
+
+ it('should display "Within Group" label', async () => {
+ const { fixture } = await setup();
+
+ const withinGroupLabel = fixture.nativeElement.querySelector('[data-qa="within-group-label"]');
+ expect(withinGroupLabel).toBeTruthy();
+ expect(withinGroupLabel.textContent.trim()).toBe('Within Group');
+ });
+
+ it('should display groupName in template', async () => {
+ const testGroupName = 'My Test Group';
+ const { fixture } = await setup({ groupName: testGroupName });
+
+ const groupNameDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupNameDisplay).toBeTruthy();
+ expect(groupNameDisplay.textContent.trim()).toBe(testGroupName);
+ });
+
+ it('should update output port name display when input changes', async () => {
+ const { fixture, component } = await setup({ outputPortName: 'Initial Output Port' });
+
+ // Verify initial display
+ let outputPortNameDisplay = fixture.nativeElement.querySelector('[data-qa="output-port-name-display"]');
+ expect(outputPortNameDisplay.textContent.trim()).toBe('Initial Output Port');
+
+ // Change the input
+ component.outputPortName = 'Updated Output Port';
+ fixture.detectChanges();
+
+ // Verify updated display
+ outputPortNameDisplay = fixture.nativeElement.querySelector('[data-qa="output-port-name-display"]');
+ expect(outputPortNameDisplay.textContent.trim()).toBe('Updated Output Port');
+ });
+
+ it('should update groupName display when input changes', async () => {
+ const { fixture, component } = await setup({ groupName: 'Initial Group' });
+
+ // Verify initial display
+ let groupNameDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupNameDisplay.textContent.trim()).toBe('Initial Group');
+
+ // Change the input
+ component.groupName = 'Updated Group';
+ fixture.detectChanges();
+
+ // Verify updated display
+ groupNameDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupNameDisplay.textContent.trim()).toBe('Updated Group');
+ });
+
+ it('should set title attribute for output port name element', async () => {
+ const testOutputPortName = 'Output Port with Title';
+ const { fixture } = await setup({ outputPortName: testOutputPortName });
+
+ const outputPortNameDisplay = fixture.nativeElement.querySelector('[data-qa="output-port-name-display"]');
+ expect(outputPortNameDisplay.getAttribute('title')).toBe(testOutputPortName);
+ });
+
+ it('should set title attribute for groupName element', async () => {
+ const testGroupName = 'Group with Title';
+ const { fixture } = await setup({ groupName: testGroupName });
+
+ const groupNameDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupNameDisplay.getAttribute('title')).toBe(testGroupName);
+ });
});
});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-process-group/destination-process-group.component.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-process-group/destination-process-group.component.html
index 889bbc9..2d73965 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-process-group/destination-process-group.component.html
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-process-group/destination-process-group.component.html
@@ -17,19 +17,21 @@
<div class="flex flex-col gap-y-4">
@if (noPorts || hasUnauthorizedPorts) {
- <div class="flex flex-col mb-5">
- <div>To Input</div>
+ <div class="flex flex-col mb-5" data-qa="error-section">
+ <div data-qa="to-input-error-label">To Input</div>
@if (noPorts) {
- <mat-error>{{ groupName }} does not have any local input ports.</mat-error>
+ <mat-error data-qa="no-ports-error">{{ groupName }} does not have any local input ports.</mat-error>
}
@if (hasUnauthorizedPorts) {
- <mat-error>Not authorized for any local input ports in {{ groupName }}</mat-error>
+ <mat-error data-qa="unauthorized-ports-error"
+ >Not authorized for any local input ports in {{ groupName }}</mat-error
+ >
}
</div>
} @else {
- <mat-form-field>
- <mat-label>To Input</mat-label>
- <mat-select [(ngModel)]="selectedInputPort" (selectionChange)="handleChanged()">
+ <mat-form-field data-qa="input-port-section">
+ <mat-label data-qa="to-input-label">To Input</mat-label>
+ <mat-select [(ngModel)]="selectedInputPort" (selectionChange)="handleChanged()" data-qa="input-port-select">
@for (item of inputPortItems; track item) {
@if (item.description) {
<mat-option
@@ -49,8 +51,11 @@
</mat-form-field>
}
<div class="flex flex-col mb-5">
- <div>Within Group</div>
- <div [title]="groupName" class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium">
+ <div data-qa="within-group-label">Within Group</div>
+ <div
+ [title]="groupName"
+ class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium"
+ data-qa="group-name-display">
{{ groupName }}
</div>
</div>
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-process-group/destination-process-group.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-process-group/destination-process-group.component.spec.ts
index 07f572e..d1dd133 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-process-group/destination-process-group.component.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-process-group/destination-process-group.component.spec.ts
@@ -15,25 +15,287 @@
* limitations under the License.
*/
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-
-import { DestinationProcessGroup } from './destination-process-group.component';
+import { TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { DestinationProcessGroup } from './destination-process-group.component';
-describe('DestinationProcessGroup', () => {
- let component: DestinationProcessGroup;
- let fixture: ComponentFixture<DestinationProcessGroup>;
+describe('DestinationProcessGroup Component', () => {
+ // Mock data factories
+ function createMockProcessGroup(
+ id: string = 'process-group-id',
+ canRead: boolean = true,
+ name: string = 'Test Process Group'
+ ) {
+ return {
+ id,
+ permissions: { canRead, canWrite: true },
+ component: { name }
+ };
+ }
+
+ function createMockInputPort(
+ id: string = 'input-port-id',
+ name: string = 'Test Input Port',
+ canRead: boolean = true,
+ canWrite: boolean = true,
+ allowRemoteAccess: boolean = false,
+ comments: string = ''
+ ) {
+ return {
+ id,
+ permissions: { canRead, canWrite },
+ allowRemoteAccess,
+ component: { name, comments }
+ };
+ }
+
+ // Setup function for component configuration
+ async function setup(
+ options: {
+ processGroup?: any;
+ inputPorts?: any[];
+ } = {}
+ ) {
+ await TestBed.configureTestingModule({
+ imports: [DestinationProcessGroup, NoopAnimationsModule]
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(DestinationProcessGroup);
+ const component = fixture.componentInstance;
+
+ // Set input properties
+ if (options.processGroup !== undefined) {
+ component.processGroup = options.processGroup;
+ }
+ if (options.inputPorts !== undefined) {
+ component.inputPorts = options.inputPorts;
+ }
+
+ // Set up mock callbacks
+ component.onChange = jest.fn();
+ component.onTouched = jest.fn();
+
+ fixture.detectChanges();
+
+ return { component, fixture };
+ }
beforeEach(() => {
- TestBed.configureTestingModule({
- imports: [NoopAnimationsModule, DestinationProcessGroup]
- });
- fixture = TestBed.createComponent(DestinationProcessGroup);
- component = fixture.componentInstance;
- fixture.detectChanges();
+ jest.clearAllMocks();
});
- it('should create', () => {
- expect(component).toBeTruthy();
+ describe('Component initialization', () => {
+ it('should create', async () => {
+ const { component } = await setup();
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize with default values', async () => {
+ const { component } = await setup();
+ expect(component.isDisabled).toBe(false);
+ expect(component.isTouched).toBe(false);
+ expect(component.noPorts).toBe(false);
+ expect(component.hasUnauthorizedPorts).toBe(false);
+ });
+ });
+
+ describe('ProcessGroup input logic', () => {
+ it('should set groupName from name when canRead is true', async () => {
+ const processGroup = createMockProcessGroup('pg-1', true, 'My Process Group');
+ const { component } = await setup({ processGroup });
+
+ expect(component.groupName).toBe('My Process Group');
+ });
+
+ it('should set groupName from id when canRead is false', async () => {
+ const processGroup = createMockProcessGroup('pg-1', false, 'My Process Group');
+ const { component } = await setup({ processGroup });
+
+ expect(component.groupName).toBe('pg-1');
+ });
+
+ it('should handle null processGroup', async () => {
+ const { component } = await setup({ processGroup: null });
+ expect(component.groupName).toBeUndefined();
+ });
+ });
+
+ describe('InputPorts processing logic', () => {
+ it('should set noPorts flag when input ports array is empty', async () => {
+ const { component } = await setup({ inputPorts: [] });
+
+ expect(component.noPorts).toBe(true);
+ expect(component.hasUnauthorizedPorts).toBe(false);
+ expect(component.inputPortItems).toEqual([]);
+ });
+
+ it('should process authorized input ports correctly', async () => {
+ const inputPorts = [
+ createMockInputPort('port-1', 'Input Port 1', true, true, false, 'Description 1'),
+ createMockInputPort('port-2', 'Input Port 2', true, true, false, 'Description 2')
+ ];
+ const { component } = await setup({ inputPorts });
+
+ expect(component.noPorts).toBe(false);
+ expect(component.hasUnauthorizedPorts).toBe(false);
+ expect(component.inputPortItems).toEqual([
+ { value: 'port-1', text: 'Input Port 1', description: 'Description 1' },
+ { value: 'port-2', text: 'Input Port 2', description: 'Description 2' }
+ ]);
+ });
+
+ it('should filter unauthorized ports and set hasUnauthorizedPorts flag', async () => {
+ const inputPorts = [
+ createMockInputPort('port-1', 'Input Port 1', true, true),
+ createMockInputPort('port-2', 'Input Port 2', false, true), // no read permission
+ createMockInputPort('port-3', 'Input Port 3', true, false) // no write permission
+ ];
+ const { component } = await setup({ inputPorts });
+
+ expect(component.noPorts).toBe(false);
+ expect(component.hasUnauthorizedPorts).toBe(true);
+ expect(component.inputPortItems).toEqual([{ value: 'port-1', text: 'Input Port 1', description: '' }]);
+ });
+
+ it('should filter out remote access ports', async () => {
+ const inputPorts = [
+ createMockInputPort('port-1', 'Input Port 1', true, true, false),
+ createMockInputPort('port-2', 'Input Port 2', true, true, true) // allowRemoteAccess = true
+ ];
+ const { component } = await setup({ inputPorts });
+
+ expect(component.noPorts).toBe(false);
+ expect(component.hasUnauthorizedPorts).toBe(false);
+ expect(component.inputPortItems).toEqual([{ value: 'port-1', text: 'Input Port 1', description: '' }]);
+ });
+ });
+
+ describe('ControlValueAccessor implementation', () => {
+ it('should register onChange callback', async () => {
+ const { component } = await setup();
+ const callback = jest.fn();
+ component.registerOnChange(callback);
+ expect(component.onChange).toBe(callback);
+ });
+
+ it('should register onTouched callback', async () => {
+ const { component } = await setup();
+ const callback = jest.fn();
+ component.registerOnTouched(callback);
+ expect(component.onTouched).toBe(callback);
+ });
+
+ it('should handle disabled state', async () => {
+ const { component } = await setup();
+ component.setDisabledState(true);
+ expect(component.isDisabled).toBe(true);
+ });
+
+ it('should handle writeValue', async () => {
+ const { component } = await setup();
+ component.writeValue('test-port-id');
+ expect(component.selectedInputPort).toBe('test-port-id');
+ });
+
+ it('should handle changes and call callbacks', async () => {
+ const { component } = await setup();
+ const onChangeSpy = jest.fn();
+ const onTouchedSpy = jest.fn();
+
+ component.registerOnChange(onChangeSpy);
+ component.registerOnTouched(onTouchedSpy);
+ component.selectedInputPort = 'new-port-id';
+
+ component.handleChanged();
+
+ expect(component.isTouched).toBe(true);
+ expect(onTouchedSpy).toHaveBeenCalled();
+ expect(onChangeSpy).toHaveBeenCalledWith('new-port-id');
+ });
+
+ it('should not call onTouched when already touched', async () => {
+ const { component } = await setup();
+ const onTouchedSpy = jest.fn();
+
+ component.registerOnTouched(onTouchedSpy);
+ component.isTouched = true;
+
+ component.handleChanged();
+
+ expect(onTouchedSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Template logic', () => {
+ it('should display error section when no ports', async () => {
+ const processGroup = createMockProcessGroup('pg-1', true, 'Test Group');
+ const { fixture } = await setup({ processGroup, inputPorts: [] });
+
+ const errorSection = fixture.nativeElement.querySelector('[data-qa="error-section"]');
+ const noPortsError = fixture.nativeElement.querySelector('[data-qa="no-ports-error"]');
+ const inputPortSection = fixture.nativeElement.querySelector('[data-qa="input-port-section"]');
+
+ expect(errorSection).toBeTruthy();
+ expect(noPortsError).toBeTruthy();
+ expect(noPortsError.textContent.trim()).toBe('Test Group does not have any local input ports.');
+ expect(inputPortSection).toBeFalsy();
+ });
+
+ it('should display error section when unauthorized ports', async () => {
+ const processGroup = createMockProcessGroup('pg-1', true, 'Test Group');
+ const inputPorts = [createMockInputPort('port-1', 'Input Port 1', false, true)];
+ const { fixture } = await setup({ processGroup, inputPorts });
+
+ const errorSection = fixture.nativeElement.querySelector('[data-qa="error-section"]');
+ const unauthorizedError = fixture.nativeElement.querySelector('[data-qa="unauthorized-ports-error"]');
+ const inputPortSection = fixture.nativeElement.querySelector('[data-qa="input-port-section"]');
+
+ expect(errorSection).toBeTruthy();
+ expect(unauthorizedError).toBeTruthy();
+ expect(unauthorizedError.textContent.trim()).toBe('Not authorized for any local input ports in Test Group');
+ expect(inputPortSection).toBeFalsy();
+ });
+
+ it('should display input port section when ports are available', async () => {
+ const processGroup = createMockProcessGroup('pg-1', true, 'Test Group');
+ const inputPorts = [createMockInputPort('port-1', 'Input Port 1')];
+ const { fixture } = await setup({ processGroup, inputPorts });
+
+ const inputPortSection = fixture.nativeElement.querySelector('[data-qa="input-port-section"]');
+ const toInputLabel = fixture.nativeElement.querySelector('[data-qa="to-input-label"]');
+ const inputPortSelect = fixture.nativeElement.querySelector('[data-qa="input-port-select"]');
+ const errorSection = fixture.nativeElement.querySelector('[data-qa="error-section"]');
+
+ expect(inputPortSection).toBeTruthy();
+ expect(toInputLabel).toBeTruthy();
+ expect(toInputLabel.textContent.trim()).toBe('To Input');
+ expect(inputPortSelect).toBeTruthy();
+ expect(errorSection).toBeFalsy();
+ });
+
+ it('should display "Within Group" label', async () => {
+ const { fixture } = await setup();
+
+ const withinGroupLabel = fixture.nativeElement.querySelector('[data-qa="within-group-label"]');
+ expect(withinGroupLabel).toBeTruthy();
+ expect(withinGroupLabel.textContent.trim()).toBe('Within Group');
+ });
+
+ it('should display groupName in template', async () => {
+ const processGroup = createMockProcessGroup('pg-1', true, 'My Test Group');
+ const { fixture } = await setup({ processGroup });
+
+ const groupNameDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupNameDisplay).toBeTruthy();
+ expect(groupNameDisplay.textContent.trim()).toBe('My Test Group');
+ });
+
+ it('should set title attribute for groupName element', async () => {
+ const processGroup = createMockProcessGroup('pg-1', true, 'Group with Title');
+ const { fixture } = await setup({ processGroup });
+
+ const groupNameDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupNameDisplay.getAttribute('title')).toBe('Group with Title');
+ });
});
});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-processor/destination-processor.component.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-processor/destination-processor.component.html
index c181f8b..158f82a 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-processor/destination-processor.component.html
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-processor/destination-processor.component.html
@@ -17,16 +17,20 @@
<div class="flex flex-col gap-y-4">
<div class="flex flex-col mb-5">
- <div>To Processor</div>
+ <div data-qa="to-processor-label">To Processor</div>
<div
[title]="processorName"
- class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium">
+ class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium"
+ data-qa="processor-name-display">
{{ processorName }}
</div>
</div>
<div class="flex flex-col mb-5">
- <div>Within Group</div>
- <div [title]="groupName" class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium">
+ <div data-qa="within-group-label">Within Group</div>
+ <div
+ [title]="groupName"
+ class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium"
+ data-qa="group-name-display">
{{ groupName }}
</div>
</div>
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-processor/destination-processor.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-processor/destination-processor.component.spec.ts
index 3d8d088..1ec68cd 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-processor/destination-processor.component.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-processor/destination-processor.component.spec.ts
@@ -15,24 +15,184 @@
* limitations under the License.
*/
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-
+import { TestBed } from '@angular/core/testing';
import { DestinationProcessor } from './destination-processor.component';
-describe('DestinationProcessor', () => {
- let component: DestinationProcessor;
- let fixture: ComponentFixture<DestinationProcessor>;
+describe('DestinationProcessor Component', () => {
+ // Mock data factories
+ function createMockProcessor(
+ id: string = 'processor-id',
+ canRead: boolean = true,
+ name: string = 'Test Processor'
+ ) {
+ return {
+ id,
+ permissions: { canRead, canWrite: true },
+ component: { name }
+ };
+ }
+
+ // Setup function for component configuration
+ async function setup(
+ options: {
+ processor?: any;
+ groupName?: string;
+ } = {}
+ ) {
+ await TestBed.configureTestingModule({
+ imports: [DestinationProcessor]
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(DestinationProcessor);
+ const component = fixture.componentInstance;
+
+ // Set input properties if provided
+ if (options.processor) {
+ component.processor = options.processor;
+ }
+ if (options.groupName !== undefined) {
+ component.groupName = options.groupName;
+ }
+
+ fixture.detectChanges();
+
+ return { component, fixture };
+ }
beforeEach(() => {
- TestBed.configureTestingModule({
- imports: [DestinationProcessor]
- });
- fixture = TestBed.createComponent(DestinationProcessor);
- component = fixture.componentInstance;
- fixture.detectChanges();
+ jest.clearAllMocks();
});
- it('should create', () => {
- expect(component).toBeTruthy();
+ describe('Component initialization', () => {
+ it('should create', async () => {
+ const { component } = await setup();
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize with default values', async () => {
+ const { component } = await setup();
+ expect(component.processorName).toBeUndefined();
+ expect(component.groupName).toBeUndefined();
+ });
+ });
+
+ describe('Processor setter logic', () => {
+ it('should set processor name from component name when canRead is true', async () => {
+ const processor = createMockProcessor('proc-1', true, 'My Processor');
+ const { component } = await setup({ processor });
+
+ expect(component.processorName).toBe('My Processor');
+ });
+
+ it('should set processor name from id when canRead is false', async () => {
+ const processor = createMockProcessor('proc-1', false, 'My Processor');
+ const { component } = await setup({ processor });
+
+ expect(component.processorName).toBe('proc-1');
+ });
+
+ it('should handle null processor', async () => {
+ const { component } = await setup({ processor: null });
+ expect(component.processorName).toBeUndefined();
+ });
+
+ it('should handle processor with empty name when canRead is true', async () => {
+ const processor = createMockProcessor('proc-1', true, '');
+ const { component } = await setup({ processor });
+
+ expect(component.processorName).toBe('');
+ });
+ });
+
+ describe('GroupName property logic', () => {
+ it('should set groupName from input', async () => {
+ const { component } = await setup({ groupName: 'Test Group' });
+
+ expect(component.groupName).toBe('Test Group');
+ });
+
+ it('should handle empty groupName', async () => {
+ const { component } = await setup({ groupName: '' });
+
+ expect(component.groupName).toBe('');
+ });
+
+ it('should handle undefined groupName', async () => {
+ const { component } = await setup({ groupName: undefined });
+
+ expect(component.groupName).toBeUndefined();
+ });
+ });
+
+ describe('Template logic', () => {
+ it('should display "To Processor" label', async () => {
+ const { fixture } = await setup();
+
+ const label = fixture.nativeElement.querySelector('[data-qa="to-processor-label"]');
+ expect(label).toBeTruthy();
+ expect(label.textContent.trim()).toBe('To Processor');
+ });
+
+ it('should display "Within Group" label', async () => {
+ const { fixture } = await setup();
+
+ const label = fixture.nativeElement.querySelector('[data-qa="within-group-label"]');
+ expect(label).toBeTruthy();
+ expect(label.textContent.trim()).toBe('Within Group');
+ });
+
+ it('should display processor name when available', async () => {
+ const processor = createMockProcessor('proc-1', true, 'My Test Processor');
+ const { fixture } = await setup({ processor });
+
+ const processorDisplay = fixture.nativeElement.querySelector('[data-qa="processor-name-display"]');
+ expect(processorDisplay).toBeTruthy();
+ expect(processorDisplay.textContent.trim()).toBe('My Test Processor');
+ expect(processorDisplay.getAttribute('title')).toBe('My Test Processor');
+ });
+
+ it('should display group name when available', async () => {
+ const { fixture } = await setup({ groupName: 'My Test Group' });
+
+ const groupDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupDisplay).toBeTruthy();
+ expect(groupDisplay.textContent.trim()).toBe('My Test Group');
+ expect(groupDisplay.getAttribute('title')).toBe('My Test Group');
+ });
+
+ it('should handle empty processor name display', async () => {
+ const processor = createMockProcessor('proc-1', true, '');
+ const { fixture } = await setup({ processor });
+
+ const processorDisplay = fixture.nativeElement.querySelector('[data-qa="processor-name-display"]');
+ expect(processorDisplay).toBeTruthy();
+ expect(processorDisplay.textContent.trim()).toBe('');
+ expect(processorDisplay.getAttribute('title')).toBe('');
+ });
+
+ it('should handle empty group name display', async () => {
+ const { fixture } = await setup({ groupName: '' });
+
+ const groupDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupDisplay).toBeTruthy();
+ expect(groupDisplay.textContent.trim()).toBe('');
+ expect(groupDisplay.getAttribute('title')).toBe('');
+ });
+
+ it('should apply overflow styles to both name display elements', async () => {
+ const processor = createMockProcessor('proc-1', true, 'Test Processor');
+ const { fixture } = await setup({ processor, groupName: 'Test Group' });
+
+ const processorDisplay = fixture.nativeElement.querySelector('[data-qa="processor-name-display"]');
+ const groupDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+
+ expect(processorDisplay.classList).toContain('overflow-ellipsis');
+ expect(processorDisplay.classList).toContain('overflow-hidden');
+ expect(processorDisplay.classList).toContain('whitespace-nowrap');
+
+ expect(groupDisplay.classList).toContain('overflow-ellipsis');
+ expect(groupDisplay.classList).toContain('overflow-hidden');
+ expect(groupDisplay.classList).toContain('whitespace-nowrap');
+ });
});
});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-remote-process-group/destination-remote-process-group.component.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-remote-process-group/destination-remote-process-group.component.html
index 45018c0..7e1070b 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-remote-process-group/destination-remote-process-group.component.html
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-remote-process-group/destination-remote-process-group.component.html
@@ -17,16 +17,16 @@
<div class="flex flex-col gap-y-4">
@if (noPorts) {
- <div class="flex flex-col mb-5">
- <div>To Input</div>
+ <div class="flex flex-col mb-5" data-qa="error-section">
+ <div data-qa="to-input-error-label">To Input</div>
@if (noPorts) {
- <mat-error>{{ groupName }} does not have any input ports.</mat-error>
+ <mat-error data-qa="no-ports-error">{{ groupName }} does not have any input ports.</mat-error>
}
</div>
} @else {
- <mat-form-field>
- <mat-label>To Input</mat-label>
- <mat-select [(ngModel)]="selectedInputPort" (selectionChange)="handleChanged()">
+ <mat-form-field data-qa="input-port-section">
+ <mat-label data-qa="to-input-label">To Input</mat-label>
+ <mat-select [(ngModel)]="selectedInputPort" (selectionChange)="handleChanged()" data-qa="input-port-select">
@for (item of inputPortItems; track item) {
@if (item.description) {
<mat-option
@@ -48,8 +48,11 @@
</mat-form-field>
}
<div class="flex flex-col mb-5">
- <div>Within Group</div>
- <div [title]="groupName" class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium">
+ <div data-qa="within-group-label">Within Group</div>
+ <div
+ [title]="groupName"
+ class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium"
+ data-qa="group-name-display">
{{ groupName }}
</div>
</div>
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-remote-process-group/destination-remote-process-group.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-remote-process-group/destination-remote-process-group.component.spec.ts
index 9af17b4..61487de 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-remote-process-group/destination-remote-process-group.component.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/destination/destination-remote-process-group/destination-remote-process-group.component.spec.ts
@@ -15,25 +15,297 @@
* limitations under the License.
*/
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-
-import { DestinationRemoteProcessGroup } from './destination-remote-process-group.component';
+import { TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { DestinationRemoteProcessGroup } from './destination-remote-process-group.component';
-describe('DestinationRemoteProcessGroup', () => {
- let component: DestinationRemoteProcessGroup;
- let fixture: ComponentFixture<DestinationRemoteProcessGroup>;
+describe('DestinationRemoteProcessGroup Component', () => {
+ // Mock data factories
+ function createMockInputPort(
+ id: string = 'input-port-id',
+ name: string = 'Test Input Port',
+ comments: string = '',
+ exists: boolean = true
+ ) {
+ return {
+ id,
+ name,
+ comments,
+ exists
+ };
+ }
+
+ function createMockRemoteProcessGroup(groupName: string = 'Test Remote Process Group', inputPorts: any[] = []) {
+ return {
+ component: {
+ name: groupName,
+ contents: {
+ inputPorts
+ }
+ }
+ };
+ }
+
+ // Setup function for component configuration
+ async function setup(
+ options: {
+ remoteProcessGroup?: any;
+ } = {}
+ ) {
+ await TestBed.configureTestingModule({
+ imports: [NoopAnimationsModule, DestinationRemoteProcessGroup]
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(DestinationRemoteProcessGroup);
+ const component = fixture.componentInstance;
+
+ // Set input properties if provided
+ if (options.remoteProcessGroup) {
+ component.remoteProcessGroup = options.remoteProcessGroup;
+ }
+
+ // Set up mock callbacks
+ component.onChange = jest.fn();
+ component.onTouched = jest.fn();
+
+ fixture.detectChanges();
+
+ return { component, fixture };
+ }
beforeEach(() => {
- TestBed.configureTestingModule({
- imports: [NoopAnimationsModule, DestinationRemoteProcessGroup]
- });
- fixture = TestBed.createComponent(DestinationRemoteProcessGroup);
- component = fixture.componentInstance;
- fixture.detectChanges();
+ jest.clearAllMocks();
});
- it('should create', () => {
- expect(component).toBeTruthy();
+ describe('Component initialization', () => {
+ it('should create', async () => {
+ const { component } = await setup();
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize with default values', async () => {
+ const { component } = await setup();
+ expect(component.isDisabled).toBe(false);
+ expect(component.isTouched).toBe(false);
+ expect(component.noPorts).toBe(false);
+ });
+ });
+
+ describe('Remote process group setter logic', () => {
+ it('should set group name from remote process group', async () => {
+ const remoteProcessGroup = createMockRemoteProcessGroup('My Remote Process Group');
+ const { component } = await setup({ remoteProcessGroup });
+
+ expect(component.groupName).toBe('My Remote Process Group');
+ });
+
+ it('should process input ports correctly', async () => {
+ const inputPorts = [
+ createMockInputPort('port-1', 'Input Port 1', 'Description 1', true),
+ createMockInputPort('port-2', 'Input Port 2', 'Description 2', true)
+ ];
+ const remoteProcessGroup = createMockRemoteProcessGroup('Test Group', inputPorts);
+ const { component } = await setup({ remoteProcessGroup });
+
+ expect(component.inputPortItems).toEqual([
+ { value: 'port-1', text: 'Input Port 1', description: 'Description 1', disabled: false },
+ { value: 'port-2', text: 'Input Port 2', description: 'Description 2', disabled: false }
+ ]);
+ });
+
+ it('should handle disabled input ports when exists is false', async () => {
+ const inputPorts = [
+ createMockInputPort('port-1', 'Input Port 1', 'Description 1', true),
+ createMockInputPort('port-2', 'Input Port 2', 'Description 2', false)
+ ];
+ const remoteProcessGroup = createMockRemoteProcessGroup('Test Group', inputPorts);
+ const { component } = await setup({ remoteProcessGroup });
+
+ expect(component.inputPortItems[0].disabled).toBe(false);
+ expect(component.inputPortItems[1].disabled).toBe(true);
+ });
+
+ it('should set noPorts to true when input ports array is empty', async () => {
+ const remoteProcessGroup = createMockRemoteProcessGroup('Test Group', []);
+ const { component } = await setup({ remoteProcessGroup });
+
+ expect(component.noPorts).toBe(true);
+ expect(component.inputPortItems).toEqual([]);
+ });
+
+ it('should handle missing input ports array', async () => {
+ const remoteProcessGroup = {
+ component: {
+ name: 'Test Group',
+ contents: {} // Missing inputPorts
+ }
+ };
+ const { component } = await setup({ remoteProcessGroup });
+
+ expect(component.groupName).toBe('Test Group');
+ expect(component.inputPortItems).toBeUndefined();
+ });
+
+ it('should handle null remote process group', async () => {
+ const { component } = await setup({ remoteProcessGroup: null });
+
+ expect(component.groupName).toBeUndefined();
+ expect(component.inputPortItems).toBeUndefined();
+ });
+ });
+
+ describe('ControlValueAccessor implementation', () => {
+ it('should register onChange callback', async () => {
+ const { component } = await setup();
+ const callback = jest.fn();
+ component.registerOnChange(callback);
+ expect(component.onChange).toBe(callback);
+ });
+
+ it('should register onTouched callback', async () => {
+ const { component } = await setup();
+ const callback = jest.fn();
+ component.registerOnTouched(callback);
+ expect(component.onTouched).toBe(callback);
+ });
+
+ it('should handle disabled state', async () => {
+ const { component } = await setup();
+ component.setDisabledState(true);
+ expect(component.isDisabled).toBe(true);
+ });
+
+ it('should handle writeValue', async () => {
+ const { component } = await setup();
+ component.writeValue('test-port-id');
+ expect(component.selectedInputPort).toBe('test-port-id');
+ });
+
+ it('should handle value changes and call callbacks', async () => {
+ const { component } = await setup();
+ const mockOnChange = jest.fn();
+ const mockOnTouched = jest.fn();
+
+ component.registerOnChange(mockOnChange);
+ component.registerOnTouched(mockOnTouched);
+ component.selectedInputPort = 'new-port-id';
+
+ component.handleChanged();
+
+ expect(component.isTouched).toBe(true);
+ expect(mockOnTouched).toHaveBeenCalled();
+ expect(mockOnChange).toHaveBeenCalledWith('new-port-id');
+ });
+
+ it('should not call onTouched multiple times', async () => {
+ const { component } = await setup();
+ const mockOnChange = jest.fn();
+ const mockOnTouched = jest.fn();
+
+ component.registerOnChange(mockOnChange);
+ component.registerOnTouched(mockOnTouched);
+ component.isTouched = true;
+
+ component.handleChanged();
+
+ expect(mockOnTouched).not.toHaveBeenCalled();
+ expect(mockOnChange).toHaveBeenCalled();
+ });
+ });
+
+ describe('Template logic', () => {
+ it('should display error section when no ports available', async () => {
+ const remoteProcessGroup = createMockRemoteProcessGroup('Test Group', []);
+ const { fixture } = await setup({ remoteProcessGroup });
+
+ const errorSection = fixture.nativeElement.querySelector('[data-qa="error-section"]');
+ expect(errorSection).toBeTruthy();
+
+ const errorLabel = fixture.nativeElement.querySelector('[data-qa="to-input-error-label"]');
+ expect(errorLabel).toBeTruthy();
+ expect(errorLabel.textContent.trim()).toBe('To Input');
+
+ const noPortsError = fixture.nativeElement.querySelector('[data-qa="no-ports-error"]');
+ expect(noPortsError).toBeTruthy();
+ expect(noPortsError.textContent.trim()).toBe('Test Group does not have any input ports.');
+ });
+
+ it('should display select dropdown when ports are available', async () => {
+ const inputPorts = [
+ createMockInputPort('port-1', 'Input Port 1'),
+ createMockInputPort('port-2', 'Input Port 2')
+ ];
+ const remoteProcessGroup = createMockRemoteProcessGroup('Test Group', inputPorts);
+ const { fixture } = await setup({ remoteProcessGroup });
+
+ const toInputLabel = fixture.nativeElement.querySelector('[data-qa="to-input-label"]');
+ expect(toInputLabel).toBeTruthy();
+ expect(toInputLabel.textContent.trim()).toBe('To Input');
+
+ const inputPortSelect = fixture.nativeElement.querySelector('[data-qa="input-port-select"]');
+ expect(inputPortSelect).toBeTruthy();
+
+ // Should not show error section
+ const errorSection = fixture.nativeElement.querySelector('[data-qa="error-section"]');
+ expect(errorSection).toBeFalsy();
+ });
+
+ it('should display "Within Group" label', async () => {
+ const { fixture } = await setup();
+
+ const withinGroupLabel = fixture.nativeElement.querySelector('[data-qa="within-group-label"]');
+ expect(withinGroupLabel).toBeTruthy();
+ expect(withinGroupLabel.textContent.trim()).toBe('Within Group');
+ });
+
+ it('should display group name when available', async () => {
+ const remoteProcessGroup = createMockRemoteProcessGroup('My Test Remote Group');
+ const { fixture } = await setup({ remoteProcessGroup });
+
+ const groupNameDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupNameDisplay).toBeTruthy();
+ expect(groupNameDisplay.textContent.trim()).toBe('My Test Remote Group');
+ expect(groupNameDisplay.getAttribute('title')).toBe('My Test Remote Group');
+ });
+
+ it('should handle empty group name display', async () => {
+ const remoteProcessGroup = createMockRemoteProcessGroup('');
+ const { fixture } = await setup({ remoteProcessGroup });
+
+ const groupNameDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupNameDisplay).toBeTruthy();
+ expect(groupNameDisplay.textContent.trim()).toBe('');
+ expect(groupNameDisplay.getAttribute('title')).toBe('');
+ });
+
+ it('should apply overflow styles to group name display', async () => {
+ const remoteProcessGroup = createMockRemoteProcessGroup('Test Group');
+ const { fixture } = await setup({ remoteProcessGroup });
+
+ const groupNameDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupNameDisplay.classList).toContain('overflow-ellipsis');
+ expect(groupNameDisplay.classList).toContain('overflow-hidden');
+ expect(groupNameDisplay.classList).toContain('whitespace-nowrap');
+ });
+
+ it('should toggle between error and select display based on ports availability', async () => {
+ const { component, fixture } = await setup();
+
+ // Start with no ports
+ component.remoteProcessGroup = createMockRemoteProcessGroup('Test Group', []);
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.querySelector('[data-qa="error-section"]')).toBeTruthy();
+ expect(fixture.nativeElement.querySelector('[data-qa="input-port-select"]')).toBeFalsy();
+
+ // Add ports
+ component.remoteProcessGroup = createMockRemoteProcessGroup('Test Group', [
+ createMockInputPort('port-1', 'Port 1')
+ ]);
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.querySelector('[data-qa="error-section"]')).toBeFalsy();
+ expect(fixture.nativeElement.querySelector('[data-qa="input-port-select"]')).toBeTruthy();
+ });
});
});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/edit-connection/edit-connection.component.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/edit-connection/edit-connection.component.html
index 72bdc91..200e221 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/edit-connection/edit-connection.component.html
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/edit-connection/edit-connection.component.html
@@ -15,12 +15,15 @@
~ limitations under the License.
-->
-<h2 mat-dialog-title>
+<h2 mat-dialog-title data-qa="dialog-title">
{{ connectionReadonly || sourceReadonly || destinationReadonly ? 'Connection Details' : 'Edit Connection' }}
</h2>
-<form class="edit-connection-form" [formGroup]="editConnectionForm">
+<form class="edit-connection-form" [formGroup]="editConnectionForm" data-qa="edit-connection-form">
<context-error-banner [context]="ErrorContextKey.CONNECTION"></context-error-banner>
- <mat-tab-group [(selectedIndex)]="selectedIndex" (selectedIndexChange)="tabChanged($event)">
+ <mat-tab-group
+ [(selectedIndex)]="selectedIndex"
+ (selectedIndexChange)="tabChanged($event)"
+ data-qa="connection-tabs">
<mat-tab label="Details">
<mat-dialog-content>
@if (breadcrumbs$ | async; as breadcrumbs) {
@@ -178,7 +181,8 @@
</mat-label>
<mat-select
formControlName="loadBalanceStrategy"
- (selectionChange)="loadBalanceChanged($event.value)">
+ (selectionChange)="loadBalanceChanged($event.value)"
+ data-qa="load-balance-strategy-select">
@for (option of loadBalanceStrategies; track option) {
<mat-option
[value]="option.value"
@@ -194,7 +198,7 @@
</mat-form-field>
</div>
@if (loadBalancePartitionAttributeRequired) {
- <div class="w-full">
+ <div class="w-full" data-qa="partition-attribute-section">
<mat-form-field>
<mat-label>
Attribute Name
@@ -208,13 +212,14 @@
matInput
formControlName="partitionAttribute"
type="text"
- [readonly]="connectionReadonly || sourceReadonly || destinationReadonly" />
+ [readonly]="connectionReadonly || sourceReadonly || destinationReadonly"
+ data-qa="partition-attribute-input" />
</mat-form-field>
</div>
}
</div>
@if (loadBalanceCompressionRequired) {
- <div>
+ <div data-qa="compression-section">
<mat-form-field>
<mat-label>
Load Balance Compression
@@ -224,7 +229,7 @@
[tooltipComponentType]="TextTip"
tooltipInputData="Whether or not data should be compressed when being transferred between nodes in the cluster."></i>
</mat-label>
- <mat-select formControlName="compression">
+ <mat-select formControlName="compression" data-qa="compression-select">
@for (option of loadBalanceCompressionStrategies; track option) {
<mat-option
[value]="option.value"
@@ -253,9 +258,9 @@
@if ({ value: (saving$ | async)! }; as saving) {
<mat-dialog-actions align="end">
@if (connectionReadonly || sourceReadonly || destinationReadonly) {
- <button mat-flat-button mat-dialog-close>Close</button>
+ <button mat-flat-button mat-dialog-close data-qa="close-button">Close</button>
} @else {
- <button mat-button mat-dialog-close="CANCELLED">Cancel</button>
+ <button mat-button mat-dialog-close="CANCELLED" data-qa="cancel-button">Cancel</button>
<button
[disabled]="
!editConnectionForm.dirty ||
@@ -265,7 +270,8 @@
"
type="button"
(click)="editConnection()"
- mat-flat-button>
+ mat-flat-button
+ data-qa="apply-button">
<span *nifiSpinner="saving.value">Apply</span>
</button>
}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/edit-connection/edit-connection.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/edit-connection/edit-connection.component.spec.ts
index 59d1419..7040f39 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/edit-connection/edit-connection.component.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/edit-connection/edit-connection.component.spec.ts
@@ -15,15 +15,9 @@
* limitations under the License.
*/
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-
-import { EditConnectionComponent } from './edit-connection.component';
+import { TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
-import { EditConnectionDialogRequest } from '../../../../../state/flow';
-import { ComponentType } from '@nifi/shared';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
-import { initialState } from '../../../../../state/flow/flow.reducer';
-import { selectPrioritizerTypes } from '../../../../../../../state/extension-types/extension-types.selectors';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { initialState as initialErrorState } from '../../../../../../../state/error/error.reducer';
import { errorFeatureKey } from '../../../../../../../state/error';
@@ -32,148 +26,526 @@
import { canvasFeatureKey } from '../../../../../state';
import { flowFeatureKey } from '../../../../../state/flow';
+import { EditConnectionComponent } from './edit-connection.component';
+import { EditConnectionDialogRequest } from '../../../../../state/flow';
+import { ComponentType } from '@nifi/shared';
+import { initialState } from '../../../../../state/flow/flow.reducer';
+import { selectPrioritizerTypes } from '../../../../../../../state/extension-types/extension-types.selectors';
+import { selectBreadcrumbs, selectSaving } from '../../../../../state/flow/flow.selectors';
+import { updateConnection } from '../../../../../state/flow/flow.actions';
+
describe('EditConnectionComponent', () => {
- let store: MockStore;
- let component: EditConnectionComponent;
- let fixture: ComponentFixture<EditConnectionComponent>;
+ // Mock data factories
+ function createMockConnection(
+ sourceType: string = 'INPUT_PORT',
+ destinationType: string = 'OUTPUT_PORT',
+ options: any = {}
+ ) {
+ return {
+ id: options.id || 'connection-id',
+ source: {
+ id: options.sourceId || 'source-id',
+ type: sourceType,
+ groupId: options.sourceGroupId || 'source-group-id',
+ name: options.sourceName || 'Source Component'
+ },
+ destination: {
+ id: options.destinationId || 'destination-id',
+ type: destinationType,
+ groupId: options.destinationGroupId || 'destination-group-id',
+ name: options.destinationName || 'Destination Component'
+ },
+ name: options.name !== undefined ? options.name : 'Test Connection',
+ backPressureObjectThreshold: options.backPressureObjectThreshold || 10000,
+ backPressureDataSizeThreshold: options.backPressureDataSizeThreshold || '1 GB',
+ flowFileExpiration: options.flowFileExpiration || '0 sec',
+ prioritizers: options.prioritizers || [],
+ loadBalanceStrategy: options.loadBalanceStrategy || 'DO_NOT_LOAD_BALANCE',
+ loadBalancePartitionAttribute: options.loadBalancePartitionAttribute || '',
+ loadBalanceCompression: options.loadBalanceCompression || 'DO_NOT_COMPRESS',
+ selectedRelationships: options.selectedRelationships || []
+ };
+ }
- const data: EditConnectionDialogRequest = {
- type: ComponentType.Connection,
- uri: 'https://localhost:4200/nifi-api/connections/abd5a02c-018b-1000-c602-fe83979f1997',
- entity: {
- revision: {
- version: 0
+ function createMockDialogRequest(
+ connection: any,
+ permissions: any = { canRead: true, canWrite: true },
+ newDestination?: any
+ ): EditConnectionDialogRequest {
+ return {
+ type: ComponentType.Connection,
+ uri: `https://localhost:4200/nifi-api/connections/${connection.id}`,
+ entity: {
+ revision: { version: 0 },
+ id: connection.id,
+ uri: `https://localhost:4200/nifi-api/connections/${connection.id}`,
+ permissions,
+ component: connection
},
- id: 'abd5a02c-018b-1000-c602-fe83979f1997',
- uri: 'https://localhost:4200/nifi-api/connections/abd5a02c-018b-1000-c602-fe83979f1997',
- permissions: {
- canRead: true,
- canWrite: true
- },
- component: {
- id: 'abd5a02c-018b-1000-c602-fe83979f1997',
- versionedComponentId: '8ae63ec1-bf33-3af4-a1c1-bd19d41c19ec',
- parentGroupId: '95a4b210-018b-1000-772a-5a9ebfa03287',
- source: {
- id: 'a67bf99d-018b-1000-611d-2993eb2f64b8',
- versionedComponentId: '77458ab4-8e53-3855-a682-c787a2705b9d',
- type: 'INPUT_PORT',
- groupId: '95a4b210-018b-1000-772a-5a9ebfa03287',
- name: 'in',
- running: false
- },
- destination: {
- id: 'a687e30e-018b-1000-f904-849a9f8e6bdb',
- versionedComponentId: '56cf65da-e2cd-3ec5-9d69-d73c382a9049',
- type: 'OUTPUT_PORT',
- groupId: '95a4b210-018b-1000-772a-5a9ebfa03287',
- name: 'out',
- running: false
- },
- name: '',
- labelIndex: 1,
- zIndex: 0,
- backPressureObjectThreshold: 10000,
- backPressureDataSizeThreshold: '1 GB',
- flowFileExpiration: '0 sec',
- prioritizers: [],
- bends: [],
- loadBalanceStrategy: 'DO_NOT_LOAD_BALANCE',
- loadBalancePartitionAttribute: '',
- loadBalanceCompression: 'DO_NOT_COMPRESS',
- loadBalanceStatus: 'LOAD_BALANCE_NOT_CONFIGURED'
- },
- status: {
- id: 'abd5a02c-018b-1000-c602-fe83979f1997',
- groupId: '95a4b210-018b-1000-772a-5a9ebfa03287',
- name: '',
- statsLastRefreshed: '09:06:47 EST',
- sourceId: 'a67bf99d-018b-1000-611d-2993eb2f64b8',
- sourceName: 'in',
- destinationId: 'a687e30e-018b-1000-f904-849a9f8e6bdb',
- destinationName: 'out',
- aggregateSnapshot: {
- id: 'abd5a02c-018b-1000-c602-fe83979f1997',
- groupId: '95a4b210-018b-1000-772a-5a9ebfa03287',
- name: '',
- sourceName: 'in',
- destinationName: 'out',
- flowFilesIn: 0,
- bytesIn: 0,
- input: '0 (0 bytes)',
- flowFilesOut: 0,
- bytesOut: 0,
- output: '0 (0 bytes)',
- flowFilesQueued: 0,
- bytesQueued: 0,
- queued: '0 (0 bytes)',
- queuedSize: '0 bytes',
- queuedCount: '0',
- percentUseCount: 0,
- percentUseBytes: 0,
- flowFileAvailability: 'ACTIVE_QUEUE_EMPTY'
- }
- },
- bends: [],
- labelIndex: 1,
- zIndex: 0,
- sourceId: 'a67bf99d-018b-1000-611d-2993eb2f64b8',
- sourceGroupId: '95a4b210-018b-1000-772a-5a9ebfa03287',
- sourceType: 'INPUT_PORT',
- destinationId: 'a687e30e-018b-1000-f904-849a9f8e6bdb',
- destinationGroupId: '95a4b210-018b-1000-772a-5a9ebfa03287',
- destinationType: 'OUTPUT_PORT'
- }
- // TODO - create separate test for existing different scenario edit scenarios
- // newDestination?: {
- // type: ComponentType | null;
- // id?: string;
- // groupId: string;
- // name: string;
- // }
- };
+ newDestination
+ };
+ }
- beforeEach(() => {
- TestBed.configureTestingModule({
+ function createMockBreadcrumb(canRead: boolean = true) {
+ return {
+ id: 'breadcrumb-id',
+ permissions: { canRead, canWrite: true },
+ breadcrumb: { id: 'breadcrumb-id', name: 'Test Breadcrumb' },
+ versionedFlowState: 'UP_TO_DATE'
+ };
+ }
+
+ function createMockPrioritizerTypes() {
+ return [
+ {
+ type: 'org.apache.nifi.prioritizer.FirstInFirstOutPrioritizer',
+ bundle: { group: 'org.apache.nifi', artifact: 'nifi-framework-nar', version: '2.0.0-SNAPSHOT' },
+ restricted: false,
+ tags: []
+ }
+ ];
+ }
+
+ // Setup function for component configuration
+ async function setup(
+ options: {
+ dialogRequest?: EditConnectionDialogRequest;
+ mockStore?: any;
+ } = {}
+ ) {
+ const defaultConnection = createMockConnection();
+ const defaultDialogRequest = options.dialogRequest || createMockDialogRequest(defaultConnection);
+
+ const storeState = {
+ ...initialState,
+ ...options.mockStore
+ };
+
+ await TestBed.configureTestingModule({
imports: [EditConnectionComponent, NoopAnimationsModule],
providers: [
- {
- provide: MAT_DIALOG_DATA,
- useValue: data
- },
+ { provide: MAT_DIALOG_DATA, useValue: defaultDialogRequest },
provideMockStore({
initialState: {
[errorFeatureKey]: initialErrorState,
[currentUserFeatureKey]: initialCurrentUserState,
[canvasFeatureKey]: {
- [flowFeatureKey]: initialState
+ [flowFeatureKey]: storeState
}
}
}),
{ provide: MatDialogRef, useValue: null }
]
- });
+ }).compileComponents();
- store = TestBed.inject(MockStore);
- store.overrideSelector(selectPrioritizerTypes, [
- {
- type: 'org.apache.nifi.prioritizer.FirstInFirstOutPrioritizer',
- bundle: {
- group: 'org.apache.nifi',
- artifact: 'nifi-framework-nar',
- version: '2.0.0-SNAPSHOT'
- },
- restricted: false,
- tags: []
- }
- ]);
+ const store = TestBed.inject(MockStore);
- fixture = TestBed.createComponent(EditConnectionComponent);
- component = fixture.componentInstance;
+ // Setup mock selectors
+ store.overrideSelector(selectPrioritizerTypes, createMockPrioritizerTypes());
+ store.overrideSelector(selectSaving, false);
+ store.overrideSelector(selectBreadcrumbs, createMockBreadcrumb());
+
+ const fixture = TestBed.createComponent(EditConnectionComponent);
+ const component = fixture.componentInstance;
+
fixture.detectChanges();
+
+ return { component, fixture, store };
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks();
});
- it('should create', () => {
- expect(component).toBeTruthy();
+ describe('Component initialization', () => {
+ it('should create', async () => {
+ const { component } = await setup();
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize with basic connection data', async () => {
+ const connection = createMockConnection();
+ const dialogRequest = createMockDialogRequest(connection);
+ const { component } = await setup({ dialogRequest });
+
+ expect(component.source).toEqual(connection.source);
+ expect(component.sourceType).toBe(ComponentType.InputPort);
+ expect(component.destinationType).toBe(ComponentType.OutputPort);
+ expect(component.destinationId).toBe(connection.destination.id);
+ });
+
+ it('should initialize form with connection values', async () => {
+ const connection = createMockConnection('INPUT_PORT', 'OUTPUT_PORT', {
+ name: 'My Connection',
+ flowFileExpiration: '30 sec',
+ backPressureObjectThreshold: 5000,
+ loadBalanceStrategy: 'ROUND_ROBIN'
+ });
+ const dialogRequest = createMockDialogRequest(connection);
+ const { component } = await setup({ dialogRequest });
+
+ expect(component.editConnectionForm.get('name')?.value).toBe('My Connection');
+ expect(component.editConnectionForm.get('flowFileExpiration')?.value).toBe('30 sec');
+ expect(component.editConnectionForm.get('backPressureObjectThreshold')?.value).toBe(5000);
+ expect(component.editConnectionForm.get('loadBalanceStrategy')?.value).toBe('ROUND_ROBIN');
+ });
+
+ it('should set readonly state based on permissions', async () => {
+ const connection = createMockConnection();
+ const dialogRequest = createMockDialogRequest(connection, { canRead: true, canWrite: false });
+ const { component } = await setup({ dialogRequest });
+
+ expect(component.connectionReadonly).toBe(true);
+ });
+ });
+
+ describe('Source component type logic', () => {
+ it('should handle Processor source type', async () => {
+ const connection = createMockConnection('PROCESSOR', 'OUTPUT_PORT', {
+ selectedRelationships: ['success', 'failure']
+ });
+ const dialogRequest = createMockDialogRequest(connection);
+ const { component } = await setup({ dialogRequest });
+
+ expect(component.sourceType).toBe(ComponentType.Processor);
+ expect(component.editConnectionForm.get('relationships')).toBeTruthy();
+ expect(component.editConnectionForm.get('relationships')?.value).toEqual(['success', 'failure']);
+ });
+
+ it('should handle ProcessGroup source type', async () => {
+ const connection = createMockConnection('OUTPUT_PORT', 'OUTPUT_PORT');
+ const dialogRequest = createMockDialogRequest(connection);
+ const { component } = await setup({ dialogRequest });
+
+ expect(component.sourceType).toBe(ComponentType.ProcessGroup);
+ expect(component.editConnectionForm.get('source')).toBeTruthy();
+ expect(component.editConnectionForm.get('source')?.disabled).toBe(true);
+ });
+
+ it('should handle RemoteProcessGroup source type', async () => {
+ const connection = createMockConnection('REMOTE_OUTPUT_PORT', 'OUTPUT_PORT');
+ const dialogRequest = createMockDialogRequest(connection);
+ const { component } = await setup({ dialogRequest });
+
+ expect(component.sourceType).toBe(ComponentType.RemoteProcessGroup);
+ expect(component.editConnectionForm.get('source')).toBeTruthy();
+ });
+
+ it('should handle InputPort source type', async () => {
+ const connection = createMockConnection('INPUT_PORT', 'OUTPUT_PORT');
+ const dialogRequest = createMockDialogRequest(connection);
+ const { component } = await setup({ dialogRequest });
+
+ expect(component.sourceType).toBe(ComponentType.InputPort);
+ expect(component.editConnectionForm.get('relationships')).toBeFalsy();
+ });
+ });
+
+ describe('Destination component type logic', () => {
+ it('should handle ProcessGroup destination type', async () => {
+ const connection = createMockConnection('INPUT_PORT', 'INPUT_PORT');
+ const dialogRequest = createMockDialogRequest(connection);
+ const { component } = await setup({ dialogRequest });
+
+ expect(component.destinationType).toBe(ComponentType.ProcessGroup);
+ expect(component.editConnectionForm.get('destination')).toBeTruthy();
+ expect(component.editConnectionForm.get('destination')?.value).toBe(connection.destination.id);
+ });
+
+ it('should handle RemoteProcessGroup destination type', async () => {
+ const connection = createMockConnection('INPUT_PORT', 'REMOTE_INPUT_PORT');
+ const dialogRequest = createMockDialogRequest(connection);
+ const { component } = await setup({ dialogRequest });
+
+ expect(component.destinationType).toBe(ComponentType.RemoteProcessGroup);
+ expect(component.editConnectionForm.get('destination')).toBeTruthy();
+ });
+
+ it('should handle Processor destination type', async () => {
+ const connection = createMockConnection('INPUT_PORT', 'PROCESSOR');
+ const dialogRequest = createMockDialogRequest(connection);
+ const { component } = await setup({ dialogRequest });
+
+ expect(component.destinationType).toBe(ComponentType.Processor);
+ expect(component.editConnectionForm.get('destination')).toBeFalsy();
+ });
+ });
+
+ describe('Load balance strategy logic', () => {
+ it('should handle DO_NOT_LOAD_BALANCE strategy', async () => {
+ const connection = createMockConnection('INPUT_PORT', 'OUTPUT_PORT', {
+ loadBalanceStrategy: 'DO_NOT_LOAD_BALANCE'
+ });
+ const dialogRequest = createMockDialogRequest(connection);
+ const { component } = await setup({ dialogRequest });
+
+ expect(component.loadBalancePartitionAttributeRequired).toBe(false);
+ expect(component.loadBalanceCompressionRequired).toBe(false);
+ expect(component.editConnectionForm.get('partitionAttribute')).toBeFalsy();
+ expect(component.editConnectionForm.get('compression')).toBeFalsy();
+ });
+
+ it('should handle PARTITION_BY_ATTRIBUTE strategy', async () => {
+ const connection = createMockConnection('INPUT_PORT', 'OUTPUT_PORT', {
+ loadBalanceStrategy: 'PARTITION_BY_ATTRIBUTE',
+ loadBalancePartitionAttribute: 'filename',
+ loadBalanceCompression: 'GZIP'
+ });
+ const dialogRequest = createMockDialogRequest(connection);
+ const { component } = await setup({ dialogRequest });
+
+ expect(component.loadBalancePartitionAttributeRequired).toBe(true);
+ expect(component.loadBalanceCompressionRequired).toBe(true);
+ expect(component.editConnectionForm.get('partitionAttribute')).toBeTruthy();
+ expect(component.editConnectionForm.get('partitionAttribute')?.value).toBe('filename');
+ expect(component.editConnectionForm.get('compression')).toBeTruthy();
+ expect(component.editConnectionForm.get('compression')?.value).toBe('GZIP');
+ });
+
+ it('should handle ROUND_ROBIN strategy', async () => {
+ const connection = createMockConnection('INPUT_PORT', 'OUTPUT_PORT', {
+ loadBalanceStrategy: 'ROUND_ROBIN',
+ loadBalanceCompression: 'SNAPPY'
+ });
+ const dialogRequest = createMockDialogRequest(connection);
+ const { component } = await setup({ dialogRequest });
+
+ expect(component.loadBalancePartitionAttributeRequired).toBe(false);
+ expect(component.loadBalanceCompressionRequired).toBe(true);
+ expect(component.editConnectionForm.get('partitionAttribute')).toBeFalsy();
+ expect(component.editConnectionForm.get('compression')).toBeTruthy();
+ });
+
+ it('should add partition attribute control when strategy changes to PARTITION_BY_ATTRIBUTE', async () => {
+ const connection = createMockConnection('INPUT_PORT', 'OUTPUT_PORT', {
+ loadBalanceStrategy: 'DO_NOT_LOAD_BALANCE'
+ });
+ const dialogRequest = createMockDialogRequest(connection);
+ const { component } = await setup({ dialogRequest });
+
+ component.loadBalanceChanged('PARTITION_BY_ATTRIBUTE');
+
+ expect(component.loadBalancePartitionAttributeRequired).toBe(true);
+ expect(component.loadBalanceCompressionRequired).toBe(true);
+ expect(component.editConnectionForm.get('partitionAttribute')).toBeTruthy();
+ expect(component.editConnectionForm.get('compression')).toBeTruthy();
+ });
+
+ it('should remove controls when strategy changes to DO_NOT_LOAD_BALANCE', async () => {
+ const connection = createMockConnection('INPUT_PORT', 'OUTPUT_PORT', {
+ loadBalanceStrategy: 'PARTITION_BY_ATTRIBUTE',
+ loadBalancePartitionAttribute: 'filename'
+ });
+ const dialogRequest = createMockDialogRequest(connection);
+ const { component } = await setup({ dialogRequest });
+
+ component.loadBalanceChanged('DO_NOT_LOAD_BALANCE');
+
+ expect(component.loadBalancePartitionAttributeRequired).toBe(false);
+ expect(component.loadBalanceCompressionRequired).toBe(false);
+ expect(component.editConnectionForm.get('partitionAttribute')).toBeFalsy();
+ expect(component.editConnectionForm.get('compression')).toBeFalsy();
+ });
+ });
+
+ describe('New destination logic', () => {
+ it('should initialize with new destination when provided', async () => {
+ const connection = createMockConnection();
+ const newDestination = {
+ type: ComponentType.Processor,
+ id: 'new-processor-id',
+ groupId: 'new-group-id',
+ name: 'New Processor'
+ };
+ const dialogRequest = createMockDialogRequest(connection, undefined, newDestination);
+ const { component } = await setup({ dialogRequest });
+
+ expect(component.destinationType).toBe(ComponentType.Processor);
+ expect(component.destinationId).toBe('new-processor-id');
+ expect(component.destinationGroupId).toBe('new-group-id');
+ expect(component.destinationName).toBe('New Processor');
+ expect(component.previousDestination).toEqual(connection.destination);
+ });
+ });
+
+ describe('Edit connection method', () => {
+ it('should dispatch updateConnection action when editConnection is called', async () => {
+ const connection = createMockConnection();
+ const dialogRequest = createMockDialogRequest(connection);
+ const { component, store } = await setup({ dialogRequest });
+
+ const dispatchSpy = jest.spyOn(store, 'dispatch');
+
+ component.editConnection();
+
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ updateConnection({
+ request: expect.objectContaining({
+ id: connection.id,
+ type: ComponentType.Connection,
+ payload: expect.objectContaining({
+ component: expect.objectContaining({
+ id: connection.id
+ })
+ })
+ })
+ })
+ );
+ });
+
+ it('should include relationships for Processor source type', async () => {
+ const connection = createMockConnection('PROCESSOR', 'OUTPUT_PORT', {
+ selectedRelationships: ['success']
+ });
+ const dialogRequest = createMockDialogRequest(connection);
+ const { component, store } = await setup({ dialogRequest });
+
+ const dispatchSpy = jest.spyOn(store, 'dispatch');
+
+ component.editConnection();
+
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ updateConnection({
+ request: expect.objectContaining({
+ payload: expect.objectContaining({
+ component: expect.objectContaining({
+ selectedRelationships: ['success']
+ })
+ })
+ })
+ })
+ );
+ });
+ });
+
+ describe('Template logic', () => {
+ it('should display "Edit Connection" title when not readonly', async () => {
+ const connection = createMockConnection();
+ const dialogRequest = createMockDialogRequest(connection, { canRead: true, canWrite: true });
+ const { fixture } = await setup({ dialogRequest });
+
+ const dialogTitle = fixture.nativeElement.querySelector('[data-qa="dialog-title"]');
+ expect(dialogTitle).toBeTruthy();
+ expect(dialogTitle.textContent.trim()).toBe('Edit Connection');
+ });
+
+ it('should display "Connection Details" title when readonly', async () => {
+ const connection = createMockConnection();
+ const dialogRequest = createMockDialogRequest(connection, { canRead: true, canWrite: false });
+ const { fixture } = await setup({ dialogRequest });
+
+ const dialogTitle = fixture.nativeElement.querySelector('[data-qa="dialog-title"]');
+ expect(dialogTitle).toBeTruthy();
+ expect(dialogTitle.textContent.trim()).toBe('Connection Details');
+ });
+
+ it('should display edit connection form', async () => {
+ const { fixture } = await setup();
+
+ const editForm = fixture.nativeElement.querySelector('[data-qa="edit-connection-form"]');
+ expect(editForm).toBeTruthy();
+
+ const connectionTabs = fixture.nativeElement.querySelector('[data-qa="connection-tabs"]');
+ expect(connectionTabs).toBeTruthy();
+ });
+
+ it('should display partition attribute section when PARTITION_BY_ATTRIBUTE is selected', async () => {
+ const connection = createMockConnection('INPUT_PORT', 'OUTPUT_PORT', {
+ loadBalanceStrategy: 'PARTITION_BY_ATTRIBUTE',
+ loadBalancePartitionAttribute: 'filename'
+ });
+ const dialogRequest = createMockDialogRequest(connection);
+ const { component, fixture } = await setup({ dialogRequest });
+
+ // Switch to Settings tab (index 1) where the partition attribute section is displayed
+ component.selectedIndex = 1;
+ fixture.detectChanges();
+
+ const partitionSection = fixture.nativeElement.querySelector('[data-qa="partition-attribute-section"]');
+ expect(partitionSection).toBeTruthy();
+
+ const partitionInput = fixture.nativeElement.querySelector('[data-qa="partition-attribute-input"]');
+ expect(partitionInput).toBeTruthy();
+ });
+
+ it('should not display partition attribute section when DO_NOT_LOAD_BALANCE is selected', async () => {
+ const connection = createMockConnection('INPUT_PORT', 'OUTPUT_PORT', {
+ loadBalanceStrategy: 'DO_NOT_LOAD_BALANCE'
+ });
+ const dialogRequest = createMockDialogRequest(connection);
+ const { component, fixture } = await setup({ dialogRequest });
+
+ // Switch to Settings tab (index 1) where the partition attribute section would be displayed
+ component.selectedIndex = 1;
+ fixture.detectChanges();
+
+ const partitionSection = fixture.nativeElement.querySelector('[data-qa="partition-attribute-section"]');
+ expect(partitionSection).toBeFalsy();
+ });
+
+ it('should display compression section when load balance strategy requires compression', async () => {
+ const connection = createMockConnection('INPUT_PORT', 'OUTPUT_PORT', {
+ loadBalanceStrategy: 'ROUND_ROBIN',
+ loadBalanceCompression: 'GZIP'
+ });
+ const dialogRequest = createMockDialogRequest(connection);
+ const { component, fixture } = await setup({ dialogRequest });
+
+ // Switch to Settings tab (index 1) where the compression section is displayed
+ component.selectedIndex = 1;
+ fixture.detectChanges();
+
+ const compressionSection = fixture.nativeElement.querySelector('[data-qa="compression-section"]');
+ expect(compressionSection).toBeTruthy();
+
+ const compressionSelect = fixture.nativeElement.querySelector('[data-qa="compression-select"]');
+ expect(compressionSelect).toBeTruthy();
+ });
+
+ it('should not display compression section when DO_NOT_LOAD_BALANCE is selected', async () => {
+ const connection = createMockConnection('INPUT_PORT', 'OUTPUT_PORT', {
+ loadBalanceStrategy: 'DO_NOT_LOAD_BALANCE'
+ });
+ const dialogRequest = createMockDialogRequest(connection);
+ const { component, fixture } = await setup({ dialogRequest });
+
+ // Switch to Settings tab (index 1) where the compression section would be displayed
+ component.selectedIndex = 1;
+ fixture.detectChanges();
+
+ const compressionSection = fixture.nativeElement.querySelector('[data-qa="compression-section"]');
+ expect(compressionSection).toBeFalsy();
+ });
+
+ it('should display Close button when readonly', async () => {
+ const connection = createMockConnection();
+ const dialogRequest = createMockDialogRequest(connection, { canRead: true, canWrite: false });
+ const { fixture } = await setup({ dialogRequest });
+
+ const closeButton = fixture.nativeElement.querySelector('[data-qa="close-button"]');
+ expect(closeButton).toBeTruthy();
+ expect(closeButton.textContent.trim()).toBe('Close');
+
+ // Should not show Cancel/Apply buttons
+ const cancelButton = fixture.nativeElement.querySelector('[data-qa="cancel-button"]');
+ const applyButton = fixture.nativeElement.querySelector('[data-qa="apply-button"]');
+ expect(cancelButton).toBeFalsy();
+ expect(applyButton).toBeFalsy();
+ });
+
+ it('should display Cancel and Apply buttons when not readonly', async () => {
+ const connection = createMockConnection();
+ const dialogRequest = createMockDialogRequest(connection, { canRead: true, canWrite: true });
+ const { fixture } = await setup({ dialogRequest });
+
+ const cancelButton = fixture.nativeElement.querySelector('[data-qa="cancel-button"]');
+ const applyButton = fixture.nativeElement.querySelector('[data-qa="apply-button"]');
+ expect(cancelButton).toBeTruthy();
+ expect(applyButton).toBeTruthy();
+
+ // Should not show Close button
+ const closeButton = fixture.nativeElement.querySelector('[data-qa="close-button"]');
+ expect(closeButton).toBeFalsy();
+ });
});
});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/prioritizers/prioritizers.component.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/prioritizers/prioritizers.component.html
index 7f4b211..b44cbdd 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/prioritizers/prioritizers.component.html
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/prioritizers/prioritizers.component.html
@@ -15,10 +15,10 @@
~ limitations under the License.
-->
-<div class="prioritizers flex flex-col gap-y-4" cdkDropListGroup>
+<div class="prioritizers flex flex-col gap-y-4" cdkDropListGroup data-qa="prioritizers-component">
@if (!isDisabled) {
- <div class="flex flex-col">
- <div>
+ <div class="flex flex-col" data-qa="available-prioritizers-section">
+ <div data-qa="available-prioritizers-label">
Available Prioritizers
<i
class="fa fa-info-circle primary-color"
@@ -31,12 +31,14 @@
cdkDropList
cdkDropListSortingDisabled
[cdkDropListData]="availablePrioritizers"
- (cdkDropListDropped)="dropAvailable($event)">
+ (cdkDropListDropped)="dropAvailable($event)"
+ data-qa="available-prioritizers-list">
@for (item of availablePrioritizers; track item; let i = $index) {
<div
class="prioritizer-draggable-item border m-1 font-bold"
cdkDrag
- cdkDragPreviewContainer="parent">
+ cdkDragPreviewContainer="parent"
+ data-qa="available-prioritizer-item">
<ng-container
*ngTemplateOutlet="
prioritizerItem;
@@ -47,8 +49,8 @@
</div>
</div>
}
- <div class="flex flex-col">
- <div>
+ <div class="flex flex-col" data-qa="selected-prioritizers-section">
+ <div data-qa="selected-prioritizers-label">
@if (!isDisabled) {
Selected Prioritizers
} @else {
@@ -67,13 +69,15 @@
cdkDropList
[cdkDropListDisabled]="isDisabled"
[cdkDropListData]="selectedPrioritizers"
- (cdkDropListDropped)="dropSelected($event)">
+ (cdkDropListDropped)="dropSelected($event)"
+ data-qa="selected-prioritizers-list">
@for (item of selectedPrioritizers; track item; let i = $index) {
<div
class="prioritizer-draggable-item border m-1 font-bold"
cdkDrag
cdkDragPreviewContainer="parent"
- [class.border-dashed]="isDisabled">
+ [class.border-dashed]="isDisabled"
+ data-qa="selected-prioritizer-item">
<ng-container
*ngTemplateOutlet="
prioritizerItem;
@@ -83,14 +87,16 @@
}
</div>
} @else {
- <div class="unset">No value set</div>
+ <div class="unset" data-qa="no-selected-prioritizers">No value set</div>
}
</div>
<ng-template #prioritizerItem let-item let-i="i" let-canClose="canClose">
@if (!isDisabled) {
<div class="flex items-center">
<span class="grip pr-5"></span>
- <div class="prioritizer-name" [title]="getPrioritizerLabel(item)">{{ getPrioritizerLabel(item) }}</div>
+ <div class="prioritizer-name" [title]="getPrioritizerLabel(item)" data-qa="prioritizer-name">
+ {{ getPrioritizerLabel(item) }}
+ </div>
@if (hasDescription(item)) {
<i
class="pl-1 fa fa-info-circle neutral-color"
@@ -100,13 +106,17 @@
}
</div>
@if (canClose) {
- <button class="pr-1" type="button" (click)="removeSelected(item, i)">
+ <button
+ class="pr-1"
+ type="button"
+ (click)="removeSelected(item, i)"
+ data-qa="remove-prioritizer-button">
<i class="fa fa-times neutral-contrast"></i>
</button>
}
} @else {
<div class="flex items-center tertiary-color font-medium">
- <div>{{ getPrioritizerLabel(item) }}</div>
+ <div data-qa="prioritizer-name-readonly">{{ getPrioritizerLabel(item) }}</div>
@if (hasDescription(item)) {
<i
class="pl-1 fa fa-info-circle neutral-color"
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/prioritizers/prioritizers.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/prioritizers/prioritizers.component.spec.ts
index b633c00..5f28209 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/prioritizers/prioritizers.component.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/prioritizers/prioritizers.component.spec.ts
@@ -15,24 +15,308 @@
* limitations under the License.
*/
-import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { TestBed } from '@angular/core/testing';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { Prioritizers } from './prioritizers.component';
+import { NiFiCommon } from '@nifi/shared';
+import { DocumentedType } from '../../../../../../../state/shared';
describe('Prioritizers', () => {
- let component: Prioritizers;
- let fixture: ComponentFixture<Prioritizers>;
+ // Mock data factories
+ function createDocumentedType(type: string, options: any = {}): DocumentedType {
+ return {
+ type,
+ bundle: {
+ group: options.bundleGroup || 'org.apache.nifi',
+ artifact: options.bundleArtifact || 'nifi-framework-nar',
+ version: options.bundleVersion || '2.0.0-SNAPSHOT'
+ },
+ description: options.description || '',
+ restricted: options.restricted || false,
+ tags: options.tags || []
+ };
+ }
+
+ function createAllPrioritizers(): DocumentedType[] {
+ return [
+ createDocumentedType('org.apache.nifi.prioritizer.FirstInFirstOutPrioritizer', {
+ description: 'A simple First-In-First-Out (FIFO) prioritizer'
+ }),
+ createDocumentedType('org.apache.nifi.prioritizer.NewestFlowFileFirstPrioritizer', {
+ description: 'Prioritizes FlowFiles based on newest first'
+ }),
+ createDocumentedType('org.apache.nifi.prioritizer.OldestFlowFileFirstPrioritizer', {
+ description: 'Prioritizes FlowFiles based on oldest first'
+ })
+ ];
+ }
+
+ const mockNiFiCommon = {
+ getComponentTypeLabel: jest.fn((type: string) => {
+ const parts = type.split('.');
+ return parts[parts.length - 1];
+ }),
+ isBlank: jest.fn((value: string) => !value || value.trim() === '')
+ };
+
+ // Setup function for component configuration
+ async function setup(
+ options: {
+ allPrioritizers?: DocumentedType[];
+ selectedPrioritizers?: string[];
+ disabled?: boolean;
+ } = {}
+ ) {
+ await TestBed.configureTestingModule({
+ imports: [Prioritizers, NoopAnimationsModule],
+ providers: [{ provide: NiFiCommon, useValue: mockNiFiCommon }]
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(Prioritizers);
+ const component = fixture.componentInstance;
+
+ // Set up mock callbacks
+ component.onChange = jest.fn();
+ component.onTouched = jest.fn();
+
+ // Initialize with empty value first
+ component.writeValue(options.selectedPrioritizers || []);
+
+ // Set allPrioritizers if provided
+ if (options.allPrioritizers) {
+ component.allPrioritizers = options.allPrioritizers;
+ }
+
+ // Set disabled state if provided
+ if (options.disabled) {
+ component.setDisabledState(options.disabled);
+ }
+
+ // Initial detection to trigger lifecycle hooks
+ fixture.detectChanges();
+
+ return { component, fixture };
+ }
beforeEach(() => {
- TestBed.configureTestingModule({
- imports: [Prioritizers]
- });
- fixture = TestBed.createComponent(Prioritizers);
- component = fixture.componentInstance;
- fixture.detectChanges();
+ jest.clearAllMocks();
});
- it('should create', () => {
- expect(component).toBeTruthy();
+ describe('Component initialization', () => {
+ it('should create', async () => {
+ const { component } = await setup();
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize with default values', async () => {
+ const { component } = await setup();
+ expect(component.isDisabled).toBe(false);
+ expect(component.isTouched).toBe(false);
+ expect(component.availablePrioritizers).toEqual([]);
+ expect(component.selectedPrioritizers).toEqual([]);
+ });
+ });
+
+ describe('AllPrioritizers input setter logic', () => {
+ it('should set allPrioritizers and process them', async () => {
+ const allPrioritizers = createAllPrioritizers();
+ const { component } = await setup({ allPrioritizers });
+
+ expect(component.availablePrioritizers).toEqual(allPrioritizers);
+ expect(component.selectedPrioritizers).toEqual([]);
+ });
+
+ it('should handle selected prioritizers correctly', async () => {
+ const allPrioritizers = createAllPrioritizers();
+ const selectedTypes = ['org.apache.nifi.prioritizer.FirstInFirstOutPrioritizer'];
+ const { component } = await setup({ allPrioritizers, selectedPrioritizers: selectedTypes });
+
+ expect(component.selectedPrioritizers).toHaveLength(1);
+ expect(component.selectedPrioritizers[0].type).toBe(
+ 'org.apache.nifi.prioritizer.FirstInFirstOutPrioritizer'
+ );
+ expect(component.availablePrioritizers).toHaveLength(2);
+ });
+
+ it('should filter selected prioritizers from available', async () => {
+ const allPrioritizers = createAllPrioritizers();
+ const selectedTypes = ['org.apache.nifi.prioritizer.FirstInFirstOutPrioritizer'];
+ const { component } = await setup({ allPrioritizers, selectedPrioritizers: selectedTypes });
+
+ const foundInAvailable = component.availablePrioritizers.find(
+ (p) => p.type === 'org.apache.nifi.prioritizer.FirstInFirstOutPrioritizer'
+ );
+ expect(foundInAvailable).toBeUndefined();
+ });
+ });
+
+ describe('ControlValueAccessor implementation', () => {
+ it('should register onChange callback', async () => {
+ const { component } = await setup();
+ const callback = jest.fn();
+ component.registerOnChange(callback);
+ expect(component.onChange).toBe(callback);
+ });
+
+ it('should register onTouched callback', async () => {
+ const { component } = await setup();
+ const callback = jest.fn();
+ component.registerOnTouched(callback);
+ expect(component.onTouched).toBe(callback);
+ });
+
+ it('should handle disabled state', async () => {
+ const { component } = await setup();
+ component.setDisabledState(true);
+ expect(component.isDisabled).toBe(true);
+ });
+
+ it('should handle writeValue', async () => {
+ const { component } = await setup();
+ const selectedTypes = ['org.apache.nifi.prioritizer.FirstInFirstOutPrioritizer'];
+ component.writeValue(selectedTypes);
+ expect(component.value).toEqual(selectedTypes);
+ });
+
+ it('should emit serialized prioritizers', async () => {
+ const allPrioritizers = createAllPrioritizers();
+ const { component } = await setup({ allPrioritizers });
+
+ const onChangeSpy = jest.fn();
+ component.registerOnChange(onChangeSpy);
+
+ // Set up selected prioritizers
+ component.writeValue(['org.apache.nifi.prioritizer.FirstInFirstOutPrioritizer']);
+
+ // Manually call handleChanged to test serialization
+ component['handleChanged']();
+
+ expect(onChangeSpy).toHaveBeenCalledWith(['org.apache.nifi.prioritizer.FirstInFirstOutPrioritizer']);
+ });
+ });
+
+ describe('removeSelected method logic', () => {
+ it('should remove selected prioritizer and update arrays', async () => {
+ const allPrioritizers = createAllPrioritizers();
+ const selectedTypes = ['org.apache.nifi.prioritizer.FirstInFirstOutPrioritizer'];
+ const { component } = await setup({ allPrioritizers, selectedPrioritizers: selectedTypes });
+
+ const onChangeSpy = jest.fn();
+ component.registerOnChange(onChangeSpy);
+
+ component.removeSelected(component.selectedPrioritizers[0], 0);
+
+ expect(component.selectedPrioritizers).toHaveLength(0);
+ expect(component.availablePrioritizers).toHaveLength(3);
+ expect(onChangeSpy).toHaveBeenCalledWith([]);
+ });
+
+ it('should call touch and change events', async () => {
+ const allPrioritizers = createAllPrioritizers();
+ const selectedTypes = ['org.apache.nifi.prioritizer.FirstInFirstOutPrioritizer'];
+ const { component } = await setup({ allPrioritizers, selectedPrioritizers: selectedTypes });
+
+ const onChangeSpy = jest.fn();
+ const onTouchedSpy = jest.fn();
+ component.registerOnChange(onChangeSpy);
+ component.registerOnTouched(onTouchedSpy);
+
+ component.removeSelected(component.selectedPrioritizers[0], 0);
+
+ expect(component.isTouched).toBe(true);
+ expect(onTouchedSpy).toHaveBeenCalled();
+ expect(onChangeSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe('Helper methods', () => {
+ it('should get prioritizer label', async () => {
+ const { component } = await setup();
+ const prioritizer = createDocumentedType('org.apache.nifi.prioritizer.FirstInFirstOutPrioritizer');
+
+ const label = component.getPrioritizerLabel(prioritizer);
+
+ expect(mockNiFiCommon.getComponentTypeLabel).toHaveBeenCalledWith(prioritizer.type);
+ expect(label).toBe('FirstInFirstOutPrioritizer');
+ });
+
+ it('should check if prioritizer has description', async () => {
+ const { component } = await setup();
+ const prioritizerWithDescription = createDocumentedType('test.Type', { description: 'Test description' });
+ const prioritizerWithoutDescription = createDocumentedType('test.Type', { description: '' });
+
+ const hasDescription1 = component.hasDescription(prioritizerWithDescription);
+ const hasDescription2 = component.hasDescription(prioritizerWithoutDescription);
+
+ expect(hasDescription1).toBe(true);
+ expect(hasDescription2).toBe(false);
+ });
+ });
+
+ describe('Template logic', () => {
+ it('should show available prioritizers section when enabled', async () => {
+ const { fixture } = await setup({ disabled: false });
+
+ const availableSection = fixture.nativeElement.querySelector('[data-qa="available-prioritizers-section"]');
+ expect(availableSection).toBeTruthy();
+ });
+
+ it('should hide available prioritizers section when disabled', async () => {
+ const { fixture } = await setup({ disabled: true });
+
+ const availableSection = fixture.nativeElement.querySelector('[data-qa="available-prioritizers-section"]');
+ expect(availableSection).toBeFalsy();
+ });
+
+ it('should show selected prioritizers list when has selected items', async () => {
+ const allPrioritizers = createAllPrioritizers();
+ const selectedTypes = ['org.apache.nifi.prioritizer.FirstInFirstOutPrioritizer'];
+ const { fixture } = await setup({ allPrioritizers, selectedPrioritizers: selectedTypes });
+
+ const selectedList = fixture.nativeElement.querySelector('[data-qa="selected-prioritizers-list"]');
+ expect(selectedList).toBeTruthy();
+ });
+
+ it('should show no selected prioritizers message when disabled and no items', async () => {
+ const { fixture } = await setup({ disabled: true });
+
+ const noSelectedMessage = fixture.nativeElement.querySelector('[data-qa="no-selected-prioritizers"]');
+ expect(noSelectedMessage).toBeTruthy();
+ expect(noSelectedMessage.textContent.trim()).toBe('No value set');
+ });
+
+ it('should display prioritizer names correctly', async () => {
+ const allPrioritizers = createAllPrioritizers();
+ const selectedTypes = ['org.apache.nifi.prioritizer.FirstInFirstOutPrioritizer'];
+ const { fixture } = await setup({ allPrioritizers, selectedPrioritizers: selectedTypes });
+
+ const prioritizerNames = fixture.nativeElement.querySelectorAll('[data-qa="prioritizer-name"]');
+ expect(prioritizerNames.length).toBeGreaterThan(0);
+
+ // Find the selected prioritizer name in the template
+ const selectedPrioritizerName = Array.from(prioritizerNames).find(
+ (element: any) => element.textContent.trim() === 'FirstInFirstOutPrioritizer'
+ );
+ expect(selectedPrioritizerName).toBeTruthy();
+ });
+
+ it('should show remove button for selected prioritizers when enabled', async () => {
+ const allPrioritizers = createAllPrioritizers();
+ const selectedTypes = ['org.apache.nifi.prioritizer.FirstInFirstOutPrioritizer'];
+ const { fixture } = await setup({ allPrioritizers, selectedPrioritizers: selectedTypes });
+
+ const removeButton = fixture.nativeElement.querySelector('[data-qa="remove-prioritizer-button"]');
+ expect(removeButton).toBeTruthy();
+ });
+
+ it('should hide remove button when disabled', async () => {
+ const allPrioritizers = createAllPrioritizers();
+ const selectedTypes = ['org.apache.nifi.prioritizer.FirstInFirstOutPrioritizer'];
+ const { fixture } = await setup({ allPrioritizers, selectedPrioritizers: selectedTypes, disabled: true });
+
+ const removeButton = fixture.nativeElement.querySelector('[data-qa="remove-prioritizer-button"]');
+ expect(removeButton).toBeFalsy();
+ });
});
});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/prioritizers/prioritizers.component.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/prioritizers/prioritizers.component.ts
index 7401980..8762dcd 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/prioritizers/prioritizers.component.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/prioritizers/prioritizers.component.ts
@@ -167,11 +167,15 @@
// mark the component as touched if not already
if (!this.isTouched) {
this.isTouched = true;
- this.onTouched();
+ if (this.onTouched) {
+ this.onTouched();
+ }
}
// emit the changes
- this.onChange(this.serializeSelectedPrioritizers());
+ if (this.onChange) {
+ this.onChange(this.serializeSelectedPrioritizers());
+ }
}
private serializeSelectedPrioritizers(): string[] {
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-funnel/source-funnel.component.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-funnel/source-funnel.component.html
index 6b96879..cfd4f20 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-funnel/source-funnel.component.html
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-funnel/source-funnel.component.html
@@ -18,11 +18,14 @@
<div class="flex flex-col gap-y-4">
<div class="flex flex-col mb-5">
<div>From Funnel</div>
- <div class="tertiary-color font-medium">funnel</div>
+ <div class="tertiary-color font-medium" data-qa="funnel-label">funnel</div>
</div>
<div class="flex flex-col mb-5">
<div>Within Group</div>
- <div [title]="groupName" class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium">
+ <div
+ [title]="groupName"
+ class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium"
+ data-qa="group-name-display">
{{ groupName }}
</div>
</div>
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-funnel/source-funnel.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-funnel/source-funnel.component.spec.ts
index 03a9e06..20b7bec 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-funnel/source-funnel.component.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-funnel/source-funnel.component.spec.ts
@@ -15,24 +15,126 @@
* limitations under the License.
*/
-import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { TestBed } from '@angular/core/testing';
import { SourceFunnel } from './source-funnel.component';
describe('SourceFunnel', () => {
- let component: SourceFunnel;
- let fixture: ComponentFixture<SourceFunnel>;
+ // Setup function for component configuration
+ async function setup(
+ options: {
+ groupName?: string;
+ } = {}
+ ) {
+ await TestBed.configureTestingModule({
+ imports: [SourceFunnel]
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(SourceFunnel);
+ const component = fixture.componentInstance;
+
+ // Set groupName if provided
+ if (options.groupName !== undefined) {
+ component.groupName = options.groupName;
+ }
+
+ // Initial detection to trigger lifecycle hooks
+ fixture.detectChanges();
+
+ return { component, fixture };
+ }
beforeEach(() => {
- TestBed.configureTestingModule({
- imports: [SourceFunnel]
- });
- fixture = TestBed.createComponent(SourceFunnel);
- component = fixture.componentInstance;
- fixture.detectChanges();
+ jest.clearAllMocks();
});
- it('should create', () => {
- expect(component).toBeTruthy();
+ describe('Component initialization', () => {
+ it('should create', async () => {
+ const { component } = await setup({ groupName: 'Test Group' });
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize with provided groupName', async () => {
+ const { component } = await setup({ groupName: 'Test Group' });
+ expect(component.groupName).toBe('Test Group');
+ });
+
+ it('should initialize without groupName', async () => {
+ const { component } = await setup();
+ expect(component.groupName).toBeUndefined();
+ });
+ });
+
+ describe('Input property logic', () => {
+ it('should accept groupName input', async () => {
+ const { component } = await setup({ groupName: 'Initial Group' });
+
+ component.groupName = 'Updated Group';
+
+ expect(component.groupName).toBe('Updated Group');
+ });
+
+ it('should handle empty groupName', async () => {
+ const { component } = await setup({ groupName: '' });
+ expect(component.groupName).toBe('');
+ });
+
+ it('should update groupName after initialization', async () => {
+ const { component, fixture } = await setup({ groupName: 'Initial Group' });
+
+ component.groupName = 'Updated Group';
+ fixture.detectChanges();
+
+ expect(component.groupName).toBe('Updated Group');
+ });
+ });
+
+ describe('Template logic', () => {
+ it('should display funnel label', async () => {
+ const { fixture } = await setup({ groupName: 'Test Group' });
+
+ const funnelLabel = fixture.nativeElement.querySelector('[data-qa="funnel-label"]');
+ expect(funnelLabel).toBeTruthy();
+ expect(funnelLabel.textContent.trim()).toBe('funnel');
+ });
+
+ it('should display groupName when provided', async () => {
+ const testGroupName = 'Test Group Name';
+ const { fixture } = await setup({ groupName: testGroupName });
+
+ const groupNameDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupNameDisplay).toBeTruthy();
+ expect(groupNameDisplay.textContent.trim()).toBe(testGroupName);
+ });
+
+ it('should set title attribute for groupName', async () => {
+ const testGroupName = 'Test Group Name';
+ const { fixture } = await setup({ groupName: testGroupName });
+
+ const groupNameDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupNameDisplay.getAttribute('title')).toBe(testGroupName);
+ });
+
+ it('should display empty groupName', async () => {
+ const { fixture } = await setup({ groupName: '' });
+
+ const groupNameDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupNameDisplay).toBeTruthy();
+ expect(groupNameDisplay.textContent.trim()).toBe('');
+ expect(groupNameDisplay.getAttribute('title')).toBe('');
+ });
+
+ it('should update display when groupName changes', async () => {
+ const { component, fixture } = await setup({ groupName: 'Initial Group' });
+
+ const groupNameDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupNameDisplay.textContent.trim()).toBe('Initial Group');
+
+ component.groupName = 'Updated Group';
+ fixture.detectChanges();
+
+ expect(groupNameDisplay.textContent.trim()).toBe('Updated Group');
+ expect(groupNameDisplay.getAttribute('title')).toBe('Updated Group');
+ });
});
});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-input-port/source-input-port.component.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-input-port/source-input-port.component.html
index 56269b7..b2ac175 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-input-port/source-input-port.component.html
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-input-port/source-input-port.component.html
@@ -18,13 +18,19 @@
<div class="flex flex-col gap-y-4">
<div class="flex flex-col mb-5">
<div>From Input</div>
- <div [title]="name" class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium">
+ <div
+ [title]="name"
+ class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium"
+ data-qa="input-port-name-display">
{{ name }}
</div>
</div>
<div class="flex flex-col mb-5">
<div>Within Group</div>
- <div [title]="groupName" class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium">
+ <div
+ [title]="groupName"
+ class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium"
+ data-qa="group-name-display">
{{ groupName }}
</div>
</div>
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-input-port/source-input-port.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-input-port/source-input-port.component.spec.ts
index 9833378..639ad4a 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-input-port/source-input-port.component.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-input-port/source-input-port.component.spec.ts
@@ -15,24 +15,189 @@
* limitations under the License.
*/
-import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { TestBed } from '@angular/core/testing';
import { SourceInputPort } from './source-input-port.component';
describe('SourceInputPort', () => {
- let component: SourceInputPort;
- let fixture: ComponentFixture<SourceInputPort>;
+ // Setup function for component configuration
+ async function setup(
+ options: {
+ inputPortName?: string;
+ groupName?: string;
+ } = {}
+ ) {
+ await TestBed.configureTestingModule({
+ imports: [SourceInputPort]
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(SourceInputPort);
+ const component = fixture.componentInstance;
+
+ // Set inputPortName if provided
+ if (options.inputPortName !== undefined) {
+ component.inputPortName = options.inputPortName;
+ }
+
+ // Set groupName if provided
+ if (options.groupName !== undefined) {
+ component.groupName = options.groupName;
+ }
+
+ // Initial detection to trigger lifecycle hooks
+ fixture.detectChanges();
+
+ return { component, fixture };
+ }
beforeEach(() => {
- TestBed.configureTestingModule({
- imports: [SourceInputPort]
- });
- fixture = TestBed.createComponent(SourceInputPort);
- component = fixture.componentInstance;
- fixture.detectChanges();
+ jest.clearAllMocks();
});
- it('should create', () => {
- expect(component).toBeTruthy();
+ describe('Component initialization', () => {
+ it('should create', async () => {
+ const { component } = await setup({ inputPortName: 'Test Input', groupName: 'Test Group' });
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize with provided inputPortName', async () => {
+ const { component } = await setup({ inputPortName: 'Test Input Port', groupName: 'Test Group' });
+ expect(component.name).toBe('Test Input Port');
+ });
+
+ it('should initialize with provided groupName', async () => {
+ const { component } = await setup({ inputPortName: 'Test Input', groupName: 'Test Group' });
+ expect(component.groupName).toBe('Test Group');
+ });
+
+ it('should initialize without inputPortName', async () => {
+ const { component } = await setup({ groupName: 'Test Group' });
+ expect(component.name).toBeUndefined();
+ });
+
+ it('should initialize without groupName', async () => {
+ const { component } = await setup({ inputPortName: 'Test Input' });
+ expect(component.groupName).toBeUndefined();
+ });
+ });
+
+ describe('InputPortName setter logic', () => {
+ it('should set name property when inputPortName is set', async () => {
+ const { component } = await setup({ groupName: 'Test Group' });
+
+ component.inputPortName = 'Custom Input Port';
+
+ expect(component.name).toBe('Custom Input Port');
+ });
+
+ it('should handle empty inputPortName', async () => {
+ const { component } = await setup({ inputPortName: '', groupName: 'Test Group' });
+ expect(component.name).toBe('');
+ });
+
+ it('should update name when inputPortName changes', async () => {
+ const { component } = await setup({ inputPortName: 'Initial Name', groupName: 'Test Group' });
+
+ expect(component.name).toBe('Initial Name');
+
+ component.inputPortName = 'Updated Name';
+
+ expect(component.name).toBe('Updated Name');
+ });
+ });
+
+ describe('Input property logic', () => {
+ it('should accept groupName input', async () => {
+ const { component } = await setup({ inputPortName: 'Test Input', groupName: 'Initial Group' });
+
+ component.groupName = 'Updated Group';
+
+ expect(component.groupName).toBe('Updated Group');
+ });
+
+ it('should handle empty groupName', async () => {
+ const { component } = await setup({ inputPortName: 'Test Input', groupName: '' });
+ expect(component.groupName).toBe('');
+ });
+ });
+
+ describe('Template logic', () => {
+ it('should display input port name when provided', async () => {
+ const testInputPortName = 'Test Input Port Name';
+ const { fixture } = await setup({ inputPortName: testInputPortName, groupName: 'Test Group' });
+
+ const inputPortNameDisplay = fixture.nativeElement.querySelector('[data-qa="input-port-name-display"]');
+ expect(inputPortNameDisplay).toBeTruthy();
+ expect(inputPortNameDisplay.textContent.trim()).toBe(testInputPortName);
+ });
+
+ it('should set title attribute for input port name', async () => {
+ const testInputPortName = 'Test Input Port Name';
+ const { fixture } = await setup({ inputPortName: testInputPortName, groupName: 'Test Group' });
+
+ const inputPortNameDisplay = fixture.nativeElement.querySelector('[data-qa="input-port-name-display"]');
+ expect(inputPortNameDisplay.getAttribute('title')).toBe(testInputPortName);
+ });
+
+ it('should display groupName when provided', async () => {
+ const testGroupName = 'Test Group Name';
+ const { fixture } = await setup({ inputPortName: 'Test Input', groupName: testGroupName });
+
+ const groupNameDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupNameDisplay).toBeTruthy();
+ expect(groupNameDisplay.textContent.trim()).toBe(testGroupName);
+ });
+
+ it('should set title attribute for groupName', async () => {
+ const testGroupName = 'Test Group Name';
+ const { fixture } = await setup({ inputPortName: 'Test Input', groupName: testGroupName });
+
+ const groupNameDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupNameDisplay.getAttribute('title')).toBe(testGroupName);
+ });
+
+ it('should display empty input port name', async () => {
+ const { fixture } = await setup({ inputPortName: '', groupName: 'Test Group' });
+
+ const inputPortNameDisplay = fixture.nativeElement.querySelector('[data-qa="input-port-name-display"]');
+ expect(inputPortNameDisplay).toBeTruthy();
+ expect(inputPortNameDisplay.textContent.trim()).toBe('');
+ expect(inputPortNameDisplay.getAttribute('title')).toBe('');
+ });
+
+ it('should display empty groupName', async () => {
+ const { fixture } = await setup({ inputPortName: 'Test Input', groupName: '' });
+
+ const groupNameDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupNameDisplay).toBeTruthy();
+ expect(groupNameDisplay.textContent.trim()).toBe('');
+ expect(groupNameDisplay.getAttribute('title')).toBe('');
+ });
+
+ it('should update display when inputPortName changes', async () => {
+ const { component, fixture } = await setup({ inputPortName: 'Initial Input', groupName: 'Test Group' });
+
+ const inputPortNameDisplay = fixture.nativeElement.querySelector('[data-qa="input-port-name-display"]');
+ expect(inputPortNameDisplay.textContent.trim()).toBe('Initial Input');
+
+ component.inputPortName = 'Updated Input';
+ fixture.detectChanges();
+
+ expect(inputPortNameDisplay.textContent.trim()).toBe('Updated Input');
+ expect(inputPortNameDisplay.getAttribute('title')).toBe('Updated Input');
+ });
+
+ it('should update display when groupName changes', async () => {
+ const { component, fixture } = await setup({ inputPortName: 'Test Input', groupName: 'Initial Group' });
+
+ const groupNameDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupNameDisplay.textContent.trim()).toBe('Initial Group');
+
+ component.groupName = 'Updated Group';
+ fixture.detectChanges();
+
+ expect(groupNameDisplay.textContent.trim()).toBe('Updated Group');
+ expect(groupNameDisplay.getAttribute('title')).toBe('Updated Group');
+ });
});
});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-process-group/source-process-group.component.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-process-group/source-process-group.component.html
index 5fdff0c..7fc6363 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-process-group/source-process-group.component.html
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-process-group/source-process-group.component.html
@@ -17,19 +17,24 @@
<div class="flex flex-col gap-y-4">
@if (noPorts || hasUnauthorizedPorts) {
- <div class="flex flex-col mb-5">
+ <div class="flex flex-col mb-5" data-qa="error-section">
<div>From Output</div>
@if (noPorts) {
- <mat-error>{{ groupName }} does not have any local output ports.</mat-error>
+ <mat-error data-qa="no-ports-error">{{ groupName }} does not have any local output ports.</mat-error>
}
@if (hasUnauthorizedPorts) {
- <mat-error>Not authorized for any local output ports in {{ groupName }}</mat-error>
+ <mat-error data-qa="unauthorized-ports-error"
+ >Not authorized for any local output ports in {{ groupName }}</mat-error
+ >
}
</div>
} @else {
<mat-form-field>
<mat-label>From Output</mat-label>
- <mat-select [(ngModel)]="selectedOutputPort" (selectionChange)="handleChanged()">
+ <mat-select
+ [(ngModel)]="selectedOutputPort"
+ (selectionChange)="handleChanged()"
+ data-qa="output-port-select">
@for (item of outputPortItems; track item) {
@if (item.description) {
<mat-option
@@ -50,7 +55,10 @@
}
<div class="flex flex-col mb-5">
<div>Within Group</div>
- <div [title]="groupName" class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium">
+ <div
+ [title]="groupName"
+ class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium"
+ data-qa="group-name-display">
{{ groupName }}
</div>
</div>
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-process-group/source-process-group.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-process-group/source-process-group.component.spec.ts
index 6246ae6..eaaae50 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-process-group/source-process-group.component.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-process-group/source-process-group.component.spec.ts
@@ -15,25 +15,330 @@
* limitations under the License.
*/
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-
-import { SourceProcessGroup } from './source-process-group.component';
+import { TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { SourceProcessGroup } from './source-process-group.component';
+
describe('SourceProcessGroup', () => {
- let component: SourceProcessGroup;
- let fixture: ComponentFixture<SourceProcessGroup>;
+ // Mock data factories
+ function createMockProcessGroup(
+ options: {
+ id?: string;
+ name?: string;
+ canRead?: boolean;
+ } = {}
+ ) {
+ const { id = 'test-process-group-id', name = 'Test Process Group', canRead = true } = options;
+
+ return {
+ id,
+ permissions: {
+ canRead
+ },
+ component: {
+ name
+ }
+ };
+ }
+
+ function createMockOutputPort(
+ options: {
+ id?: string;
+ name?: string;
+ comments?: string;
+ canRead?: boolean;
+ canWrite?: boolean;
+ allowRemoteAccess?: boolean;
+ } = {}
+ ) {
+ const {
+ id = 'test-output-port-id',
+ name = 'Test Output Port',
+ comments = 'Test output port comments',
+ canRead = true,
+ canWrite = true,
+ allowRemoteAccess = false
+ } = options;
+
+ return {
+ id,
+ permissions: {
+ canRead,
+ canWrite
+ },
+ component: {
+ name,
+ comments
+ },
+ allowRemoteAccess
+ };
+ }
+
+ // Setup function for component configuration
+ async function setup(
+ options: {
+ processGroup?: any;
+ outputPorts?: any[];
+ selectedOutputPort?: string;
+ } = {}
+ ) {
+ await TestBed.configureTestingModule({
+ imports: [NoopAnimationsModule, SourceProcessGroup]
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(SourceProcessGroup);
+ const component = fixture.componentInstance;
+
+ // Set up mock callbacks
+ component.onChange = jest.fn();
+ component.onTouched = jest.fn();
+
+ // Set processGroup if provided
+ if (options.processGroup !== undefined) {
+ component.processGroup = options.processGroup;
+ }
+
+ // Set outputPorts if provided
+ if (options.outputPorts !== undefined) {
+ component.outputPorts = options.outputPorts;
+ }
+
+ // Set selectedOutputPort if provided
+ if (options.selectedOutputPort !== undefined) {
+ component.writeValue(options.selectedOutputPort);
+ }
+
+ // Initial detection to trigger lifecycle hooks
+ fixture.detectChanges();
+
+ return { component, fixture };
+ }
beforeEach(() => {
- TestBed.configureTestingModule({
- imports: [NoopAnimationsModule, SourceProcessGroup]
- });
- fixture = TestBed.createComponent(SourceProcessGroup);
- component = fixture.componentInstance;
- fixture.detectChanges();
+ jest.clearAllMocks();
});
- it('should create', () => {
- expect(component).toBeTruthy();
+ describe('Component initialization', () => {
+ it('should create', async () => {
+ const { component } = await setup();
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize with default values', async () => {
+ const { component } = await setup();
+ expect(component.isDisabled).toBe(false);
+ expect(component.isTouched).toBe(false);
+ expect(component.noPorts).toBe(false);
+ expect(component.hasUnauthorizedPorts).toBe(false);
+ });
+ });
+
+ describe('ProcessGroup setter logic', () => {
+ it('should set groupName from process group name when canRead is true', async () => {
+ const mockProcessGroup = createMockProcessGroup({
+ name: 'Custom Process Group',
+ canRead: true
+ });
+ const { component } = await setup({ processGroup: mockProcessGroup });
+
+ expect(component.groupName).toBe('Custom Process Group');
+ });
+
+ it('should set groupName from process group ID when canRead is false', async () => {
+ const mockProcessGroup = createMockProcessGroup({
+ id: 'custom-process-group-id',
+ name: 'Custom Process Group',
+ canRead: false
+ });
+ const { component } = await setup({ processGroup: mockProcessGroup });
+
+ expect(component.groupName).toBe('custom-process-group-id');
+ });
+
+ it('should handle null process group', async () => {
+ const { component } = await setup({ processGroup: null });
+ expect(component.groupName).toBeUndefined();
+ });
+ });
+
+ describe('OutputPorts setter logic', () => {
+ it('should process authorized output ports correctly', async () => {
+ const mockOutputPorts = [
+ createMockOutputPort({ id: 'port1', name: 'Port 1', canRead: true, canWrite: true }),
+ createMockOutputPort({ id: 'port2', name: 'Port 2', canRead: true, canWrite: true })
+ ];
+ const { component } = await setup({ outputPorts: mockOutputPorts });
+
+ expect(component.outputPortItems).toHaveLength(2);
+ expect(component.outputPortItems[0]).toEqual({
+ value: 'port1',
+ text: 'Port 1',
+ description: 'Test output port comments'
+ });
+ expect(component.noPorts).toBe(false);
+ expect(component.hasUnauthorizedPorts).toBe(false);
+ });
+
+ it('should filter out unauthorized output ports', async () => {
+ const mockOutputPorts = [
+ createMockOutputPort({ id: 'port1', name: 'Port 1', canRead: true, canWrite: true }),
+ createMockOutputPort({ id: 'port2', name: 'Port 2', canRead: false, canWrite: true })
+ ];
+ const { component } = await setup({ outputPorts: mockOutputPorts });
+
+ expect(component.outputPortItems).toHaveLength(1);
+ expect(component.outputPortItems[0].value).toBe('port1');
+ expect(component.hasUnauthorizedPorts).toBe(true);
+ });
+
+ it('should filter out output ports with remote access', async () => {
+ const mockOutputPorts = [
+ createMockOutputPort({ id: 'port1', name: 'Port 1', allowRemoteAccess: false }),
+ createMockOutputPort({ id: 'port2', name: 'Port 2', allowRemoteAccess: true })
+ ];
+ const { component } = await setup({ outputPorts: mockOutputPorts });
+
+ expect(component.outputPortItems).toHaveLength(1);
+ expect(component.outputPortItems[0].value).toBe('port1');
+ });
+
+ it('should set noPorts to true when no output ports provided', async () => {
+ const { component } = await setup({ outputPorts: [] });
+
+ expect(component.noPorts).toBe(true);
+ expect(component.outputPortItems).toHaveLength(0);
+ });
+ });
+
+ describe('ControlValueAccessor implementation', () => {
+ it('should register onChange callback', async () => {
+ const { component } = await setup();
+ const callback = jest.fn();
+ component.registerOnChange(callback);
+ expect(component.onChange).toBe(callback);
+ });
+
+ it('should register onTouched callback', async () => {
+ const { component } = await setup();
+ const callback = jest.fn();
+ component.registerOnTouched(callback);
+ expect(component.onTouched).toBe(callback);
+ });
+
+ it('should handle disabled state', async () => {
+ const { component } = await setup();
+ component.setDisabledState(true);
+ expect(component.isDisabled).toBe(true);
+ });
+
+ it('should handle writeValue', async () => {
+ const { component } = await setup();
+ component.writeValue('test-output-port-id');
+ expect(component.selectedOutputPort).toBe('test-output-port-id');
+ });
+
+ it('should emit changes when handleChanged is called', async () => {
+ const { component } = await setup();
+ const onChangeSpy = jest.fn();
+ const onTouchedSpy = jest.fn();
+ component.registerOnChange(onChangeSpy);
+ component.registerOnTouched(onTouchedSpy);
+
+ component.selectedOutputPort = 'test-port-id';
+ component.handleChanged();
+
+ expect(onChangeSpy).toHaveBeenCalledWith('test-port-id');
+ expect(onTouchedSpy).toHaveBeenCalled();
+ expect(component.isTouched).toBe(true);
+ });
+
+ it('should not call onTouched again if already touched', async () => {
+ const { component } = await setup();
+ const onChangeSpy = jest.fn();
+ const onTouchedSpy = jest.fn();
+ component.registerOnChange(onChangeSpy);
+ component.registerOnTouched(onTouchedSpy);
+
+ component.isTouched = true;
+ component.selectedOutputPort = 'test-port-id';
+ component.handleChanged();
+
+ expect(onChangeSpy).toHaveBeenCalledWith('test-port-id');
+ expect(onTouchedSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Template logic', () => {
+ it('should show error section when no ports available', async () => {
+ const mockProcessGroup = createMockProcessGroup({ name: 'Test Group' });
+ const { fixture } = await setup({
+ processGroup: mockProcessGroup,
+ outputPorts: []
+ });
+
+ const errorSection = fixture.nativeElement.querySelector('[data-qa="error-section"]');
+ expect(errorSection).toBeTruthy();
+
+ const noPortsError = fixture.nativeElement.querySelector('[data-qa="no-ports-error"]');
+ expect(noPortsError).toBeTruthy();
+ expect(noPortsError.textContent.trim()).toBe('Test Group does not have any local output ports.');
+ });
+
+ it('should show unauthorized ports error when unauthorized ports exist', async () => {
+ const mockProcessGroup = createMockProcessGroup({ name: 'Test Group' });
+ const mockOutputPorts = [createMockOutputPort({ canRead: false, canWrite: true })];
+ const { fixture } = await setup({
+ processGroup: mockProcessGroup,
+ outputPorts: mockOutputPorts
+ });
+
+ const errorSection = fixture.nativeElement.querySelector('[data-qa="error-section"]');
+ expect(errorSection).toBeTruthy();
+
+ const unauthorizedError = fixture.nativeElement.querySelector('[data-qa="unauthorized-ports-error"]');
+ expect(unauthorizedError).toBeTruthy();
+ expect(unauthorizedError.textContent.trim()).toBe(
+ 'Not authorized for any local output ports in Test Group'
+ );
+ });
+
+ it('should show output port select when ports are available', async () => {
+ const mockProcessGroup = createMockProcessGroup();
+ const mockOutputPorts = [createMockOutputPort({ id: 'port1', name: 'Port 1' })];
+ const { fixture } = await setup({
+ processGroup: mockProcessGroup,
+ outputPorts: mockOutputPorts
+ });
+
+ const outputPortSelect = fixture.nativeElement.querySelector('[data-qa="output-port-select"]');
+ expect(outputPortSelect).toBeTruthy();
+
+ const errorSection = fixture.nativeElement.querySelector('[data-qa="error-section"]');
+ expect(errorSection).toBeFalsy();
+ });
+
+ it('should display group name correctly', async () => {
+ const mockProcessGroup = createMockProcessGroup({ name: 'Test Group Name' });
+ const { fixture } = await setup({ processGroup: mockProcessGroup });
+
+ const groupNameDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupNameDisplay).toBeTruthy();
+ expect(groupNameDisplay.textContent.trim()).toBe('Test Group Name');
+ expect(groupNameDisplay.getAttribute('title')).toBe('Test Group Name');
+ });
+
+ it('should update display when processGroup changes', async () => {
+ const { component, fixture } = await setup();
+
+ component.processGroup = createMockProcessGroup({ name: 'New Group Name' });
+ fixture.detectChanges();
+
+ expect(component.groupName).toBe('New Group Name');
+
+ const groupNameDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupNameDisplay.textContent.trim()).toBe('New Group Name');
+ expect(groupNameDisplay.getAttribute('title')).toBe('New Group Name');
+ });
});
});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-processor/source-processor.component.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-processor/source-processor.component.html
index 520255d..fa3eba9 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-processor/source-processor.component.html
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-processor/source-processor.component.html
@@ -18,13 +18,19 @@
<div class="flex flex-col gap-y-4">
<div class="flex flex-col mb-5">
<div>From Processor</div>
- <div [title]="name" class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium">
+ <div
+ [title]="name"
+ class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium"
+ data-qa="processor-name-display">
{{ name }}
</div>
</div>
<div class="flex flex-col mb-5">
<div>Within Group</div>
- <div [title]="groupName" class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium">
+ <div
+ [title]="groupName"
+ class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium"
+ data-qa="group-name-display">
{{ groupName }}
</div>
</div>
@@ -39,10 +45,14 @@
[(ngModel)]="item.selected"
name="selected-{{ i }}"
(change)="handleChanged()"
- [disabled]="isDisabled">
- <span [class.neutral-color]="!item.available" [class.unset]="!item.available">{{
- item.relationshipName
- }}</span>
+ [disabled]="isDisabled"
+ data-qa="relationship-checkbox">
+ <span
+ [class.neutral-color]="!item.available"
+ [class.unset]="!item.available"
+ data-qa="relationship-name">
+ {{ item.relationshipName }}
+ </span>
</mat-checkbox>
</div>
</div>
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-processor/source-processor.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-processor/source-processor.component.spec.ts
index 9c38056..55aae83 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-processor/source-processor.component.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-processor/source-processor.component.spec.ts
@@ -15,24 +15,258 @@
* limitations under the License.
*/
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-
+import { TestBed } from '@angular/core/testing';
import { SourceProcessor } from './source-processor.component';
+import { NiFiCommon } from '@nifi/shared';
-describe('SourceProcessor', () => {
- let component: SourceProcessor;
- let fixture: ComponentFixture<SourceProcessor>;
+describe('SourceProcessor Component', () => {
+ // Mock data factories
+ function createMockRelationship(name: string) {
+ return {
+ name,
+ id: `rel-${name}`,
+ available: true,
+ description: `${name} relationship`,
+ retry: true
+ };
+ }
+
+ function createMockProcessor(name: string, relationships?: any[]) {
+ return {
+ component: { name, relationships: relationships || [] }
+ };
+ }
+
+ const mockNiFiCommon = {
+ isEmpty: jest.fn((value: any) => !value || (Array.isArray(value) && value.length === 0))
+ };
+
+ // Setup function for component configuration
+ async function setup(
+ options: {
+ processor?: any;
+ selectedRelationships?: string[];
+ } = {}
+ ) {
+ await TestBed.configureTestingModule({
+ imports: [SourceProcessor],
+ providers: [{ provide: NiFiCommon, useValue: mockNiFiCommon }]
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(SourceProcessor);
+ const component = fixture.componentInstance;
+
+ // Initialize arrays to prevent undefined errors
+ component.selectedRelationships = options.selectedRelationships || [];
+ component.relationshipItems = [];
+
+ // Set processor if provided
+ if (options.processor) {
+ component.processor = options.processor;
+ }
+
+ // Set up mock callbacks
+ component.onChange = jest.fn();
+ component.onTouched = jest.fn();
+
+ // Initial detection to trigger lifecycle hooks
+ fixture.detectChanges();
+
+ return { component, fixture };
+ }
beforeEach(() => {
- TestBed.configureTestingModule({
- imports: [SourceProcessor]
- });
- fixture = TestBed.createComponent(SourceProcessor);
- component = fixture.componentInstance;
- fixture.detectChanges();
+ jest.clearAllMocks();
});
- it('should create', () => {
- expect(component).toBeTruthy();
+ describe('Component initialization', () => {
+ it('should create', async () => {
+ const { component } = await setup();
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize with default values', async () => {
+ const { component } = await setup();
+ expect(component.isDisabled).toBe(false);
+ expect(component.isTouched).toBe(false);
+ expect(component.selectedRelationships).toEqual([]);
+ expect(component.relationshipItems).toEqual([]);
+ });
+ });
+
+ describe('Processor input logic', () => {
+ it('should set processor and process relationships', async () => {
+ const processor = createMockProcessor('Test Processor', [createMockRelationship('success')]);
+ const { component } = await setup({ processor });
+
+ expect(component.name).toBe('Test Processor');
+ expect(component.relationships).toEqual(processor.component.relationships);
+ expect(component.relationshipItems).toHaveLength(1);
+ });
+
+ it('should handle null processor', async () => {
+ const { component } = await setup({ processor: null });
+ expect(component.name).toBeUndefined();
+ expect(component.relationships).toBeUndefined();
+ });
+ });
+
+ describe('ProcessRelationships method logic', () => {
+ it('should auto-select single relationship when no selected relationships', async () => {
+ const processor = createMockProcessor('Test', [createMockRelationship('success')]);
+ mockNiFiCommon.isEmpty.mockReturnValue(true);
+ const { component } = await setup({ processor });
+
+ expect(component.relationshipItems[0].selected).toBe(true);
+ });
+
+ it('should map existing selected relationships', async () => {
+ const processor = createMockProcessor('Test', [
+ createMockRelationship('success'),
+ createMockRelationship('failure')
+ ]);
+ mockNiFiCommon.isEmpty.mockReturnValue(false);
+ const { component } = await setup({ processor, selectedRelationships: ['success'] });
+
+ expect(component.relationshipItems[0].selected).toBe(true);
+ expect(component.relationshipItems[1].selected).toBe(false);
+ });
+
+ it('should mark unavailable relationships correctly', async () => {
+ const processor = createMockProcessor('Test', [createMockRelationship('success')]);
+ mockNiFiCommon.isEmpty.mockReturnValue(false);
+ const { component } = await setup({ processor, selectedRelationships: ['nonexistent'] });
+
+ expect(component.relationshipItems[0].available).toBe(true);
+ expect(component.relationshipItems.find((item) => item.relationshipName === 'nonexistent')?.available).toBe(
+ false
+ );
+ });
+ });
+
+ describe('considerDefaultSelection method logic', () => {
+ it('should not call handleChanged when no callbacks registered', async () => {
+ const processor = createMockProcessor('Test', [createMockRelationship('success')]);
+ const { component } = await setup({ processor });
+
+ // Clear callbacks to test null handling
+ component.onChange = null;
+ component.onTouched = null;
+
+ // This should not throw errors when callbacks are null
+ component.considerDefaultSelection();
+ expect(component.selectedRelationships).toBeDefined();
+ });
+
+ it('should call handleChanged for single relationship when callbacks exist', async () => {
+ const processor = createMockProcessor('Test', [createMockRelationship('success')]);
+ mockNiFiCommon.isEmpty.mockReturnValue(true);
+ const { component } = await setup({ processor });
+
+ const onChangeSpy = jest.fn();
+ component.registerOnChange(onChangeSpy);
+
+ // This test checks that considerDefaultSelection calls handleChanged for auto-selected single relationships
+ component.considerDefaultSelection();
+
+ expect(onChangeSpy).toHaveBeenCalledWith(['success']);
+ });
+ });
+
+ describe('ControlValueAccessor implementation', () => {
+ it('should register onChange callback', async () => {
+ const { component } = await setup();
+ const callback = jest.fn();
+ component.registerOnChange(callback);
+ expect(component.onChange).toBe(callback);
+ });
+
+ it('should register onTouched callback', async () => {
+ const { component } = await setup();
+ const callback = jest.fn();
+ component.registerOnTouched(callback);
+ expect(component.onTouched).toBe(callback);
+ });
+
+ it('should handle disabled state', async () => {
+ const { component } = await setup();
+ component.setDisabledState(true);
+ expect(component.isDisabled).toBe(true);
+ });
+
+ it('should handle writeValue', async () => {
+ const { component } = await setup();
+ component.writeValue(['success']);
+ expect(component.selectedRelationships).toEqual(['success']);
+ });
+
+ it('should emit serialized relationships', async () => {
+ // Mock the private method by setting up relationshipItems directly
+ const { component } = await setup();
+ component.relationshipItems = [{ relationshipName: 'success', selected: true, available: true }];
+
+ component.handleChanged();
+
+ expect(component.onChange).toHaveBeenCalledWith(['success']);
+ });
+ });
+
+ describe('Template logic', () => {
+ it('should display processor name when available', async () => {
+ const processor = createMockProcessor('Test Processor');
+ const { fixture } = await setup({ processor });
+
+ const processorNameDisplay = fixture.nativeElement.querySelector('[data-qa="processor-name-display"]');
+ expect(processorNameDisplay.textContent.trim()).toBe('Test Processor');
+ });
+
+ it('should display relationships when available', async () => {
+ const processor = createMockProcessor('Test', [createMockRelationship('success')]);
+ const { fixture } = await setup({ processor });
+
+ const checkboxes = fixture.nativeElement.querySelectorAll('[data-qa="relationship-checkbox"]');
+ expect(checkboxes).toHaveLength(1);
+ });
+
+ it('should only show selected relationships when disabled', async () => {
+ const relationships = [createMockRelationship('success'), createMockRelationship('failure')];
+ const processor = createMockProcessor('Test', relationships);
+ mockNiFiCommon.isEmpty.mockReturnValue(false);
+ const { component, fixture } = await setup({ processor, selectedRelationships: ['success'] });
+
+ // Ensure the component has proper state before setting disabled
+ expect(component.relationshipItems).toHaveLength(2);
+ expect(component.relationshipItems[0].selected).toBe(true);
+
+ component.setDisabledState(true);
+ fixture.detectChanges();
+
+ const visibleCheckboxes = fixture.nativeElement.querySelectorAll('[data-qa="relationship-checkbox"]');
+ expect(visibleCheckboxes.length).toBeLessThanOrEqual(2);
+ });
+
+ it('should display relationship names correctly', async () => {
+ const processor = createMockProcessor('Test', [createMockRelationship('success')]);
+ const { fixture } = await setup({ processor });
+
+ const relationshipNames = fixture.nativeElement.querySelectorAll('[data-qa="relationship-name"]');
+ expect(relationshipNames.length).toBeGreaterThan(0);
+
+ const successRelationship = Array.from(relationshipNames).find(
+ (element: any) => element.textContent.trim() === 'success'
+ );
+ expect(successRelationship).toBeTruthy();
+ });
+
+ it('should display group name when available', async () => {
+ const processor = createMockProcessor('Test');
+ const { component, fixture } = await setup({ processor });
+
+ component.groupName = 'Test Group';
+ fixture.detectChanges();
+
+ const groupDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+ expect(groupDisplay.textContent.trim()).toBe('Test Group');
+ });
});
});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-remote-process-group/source-remote-process-group.component.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-remote-process-group/source-remote-process-group.component.html
index e44ff20..cb6be8f 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-remote-process-group/source-remote-process-group.component.html
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-remote-process-group/source-remote-process-group.component.html
@@ -15,18 +15,21 @@
~ limitations under the License.
-->
-<div class="flex flex-col gap-y-4">
+<div class="flex flex-col gap-y-4" data-qa="source-remote-process-group-container">
@if (noPorts) {
- <div class="flex flex-col mb-5">
+ <div class="flex flex-col mb-5" data-qa="no-ports-section">
<div>From Output</div>
@if (noPorts) {
- <mat-error>{{ groupName }} does not have any local output ports.</mat-error>
+ <mat-error data-qa="no-ports-error">{{ groupName }} does not have any local output ports.</mat-error>
}
</div>
} @else {
- <mat-form-field>
+ <mat-form-field data-qa="output-port-section">
<mat-label>From Output</mat-label>
- <mat-select [(ngModel)]="selectedOutputPort" (selectionChange)="handleChanged()">
+ <mat-select
+ [(ngModel)]="selectedOutputPort"
+ (selectionChange)="handleChanged()"
+ data-qa="output-port-select">
@for (item of outputPortItems; track item) {
@if (item.description) {
<mat-option
@@ -49,7 +52,10 @@
}
<div class="flex flex-col mb-5">
<div>Within Group</div>
- <div [title]="groupName" class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium">
+ <div
+ [title]="groupName"
+ class="tertiary-color overflow-ellipsis overflow-hidden whitespace-nowrap font-medium"
+ data-qa="group-name-display">
{{ groupName }}
</div>
</div>
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-remote-process-group/source-remote-process-group.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-remote-process-group/source-remote-process-group.component.spec.ts
index b98d0dd..194ba06 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-remote-process-group/source-remote-process-group.component.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-remote-process-group/source-remote-process-group.component.spec.ts
@@ -15,25 +15,291 @@
* limitations under the License.
*/
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-
-import { SourceRemoteProcessGroup } from './source-remote-process-group.component';
+import { TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { SourceRemoteProcessGroup } from './source-remote-process-group.component';
describe('SourceRemoteProcessGroup', () => {
- let component: SourceRemoteProcessGroup;
- let fixture: ComponentFixture<SourceRemoteProcessGroup>;
+ // Mock data factories
+ function createMockOutputPort(id: string, hasDescription = false, exists = true): any {
+ const port: any = {
+ id,
+ name: id,
+ exists
+ };
+
+ if (hasDescription) {
+ port.comments = `Description for ${id}`;
+ }
+
+ return port;
+ }
+
+ function createMockRemoteProcessGroup(name: string, outputPorts: any[] = []): any {
+ return {
+ component: {
+ name,
+ contents: {
+ outputPorts
+ }
+ }
+ };
+ }
+
+ // Setup function for component configuration
+ async function setup(
+ options: {
+ remoteProcessGroup?: any;
+ selectedOutputPort?: string | null;
+ } = {}
+ ) {
+ await TestBed.configureTestingModule({
+ imports: [NoopAnimationsModule, SourceRemoteProcessGroup]
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(SourceRemoteProcessGroup);
+ const component = fixture.componentInstance;
+
+ // Set up component state
+ if (options.remoteProcessGroup !== undefined) {
+ component.remoteProcessGroup = options.remoteProcessGroup;
+ }
+ if (options.selectedOutputPort !== undefined && options.selectedOutputPort !== null) {
+ component.writeValue(options.selectedOutputPort);
+ }
+
+ // Set up mock callbacks
+ component.onChange = jest.fn();
+ component.onTouched = jest.fn();
+
+ fixture.detectChanges();
+
+ return { component, fixture };
+ }
beforeEach(() => {
- TestBed.configureTestingModule({
- imports: [NoopAnimationsModule, SourceRemoteProcessGroup]
- });
- fixture = TestBed.createComponent(SourceRemoteProcessGroup);
- component = fixture.componentInstance;
- fixture.detectChanges();
+ jest.clearAllMocks();
});
- it('should create', () => {
- expect(component).toBeTruthy();
+ describe('Component initialization', () => {
+ it('should create', async () => {
+ const { component } = await setup();
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize with default values', async () => {
+ const { component } = await setup();
+ expect(component.isDisabled).toBe(false);
+ expect(component.isTouched).toBe(false);
+ expect(component.noPorts).toBe(false);
+ expect(component.selectedOutputPort).toBeUndefined();
+ });
+ });
+
+ describe('RemoteProcessGroup input logic', () => {
+ it('should set group name and create output port items when remote process group provided', async () => {
+ const outputPorts = [createMockOutputPort('port1'), createMockOutputPort('port2')];
+ const rpg = createMockRemoteProcessGroup('TestGroup', outputPorts);
+
+ const { component } = await setup({ remoteProcessGroup: rpg });
+
+ expect(component.groupName).toBe('TestGroup');
+ expect(component.outputPortItems).toHaveLength(2);
+ expect(component.outputPortItems[0].text).toBe('port1');
+ expect(component.outputPortItems[1].text).toBe('port2');
+ });
+
+ it('should handle remote process group with no ports', async () => {
+ const rpg = createMockRemoteProcessGroup('EmptyGroup', []);
+ const { component } = await setup({ remoteProcessGroup: rpg });
+
+ expect(component.groupName).toBe('EmptyGroup');
+ expect(component.noPorts).toBe(true);
+ expect(component.outputPortItems).toHaveLength(0);
+ });
+
+ it('should handle null remote process group', async () => {
+ const { component } = await setup({ remoteProcessGroup: null });
+
+ expect(component.groupName).toBeUndefined();
+ expect(component.outputPortItems).toBeUndefined();
+ expect(component.noPorts).toBe(false);
+ });
+ });
+
+ describe('Output port processing logic', () => {
+ it('should create output port items with descriptions', async () => {
+ const outputPorts = [createMockOutputPort('port1', true), createMockOutputPort('port2', false)];
+ const rpg = createMockRemoteProcessGroup('TestGroup', outputPorts);
+
+ const { component } = await setup({ remoteProcessGroup: rpg });
+
+ expect(component.outputPortItems[0].description).toBe('Description for port1');
+ expect(component.outputPortItems[1].description).toBeUndefined();
+ });
+
+ it('should mark non-existent ports as disabled', async () => {
+ const outputPorts = [
+ createMockOutputPort('port1', false, true),
+ createMockOutputPort('port2', false, false)
+ ];
+ const rpg = createMockRemoteProcessGroup('TestGroup', outputPorts);
+
+ const { component } = await setup({ remoteProcessGroup: rpg });
+
+ expect(component.outputPortItems[0].disabled).toBe(false);
+ expect(component.outputPortItems[1].disabled).toBe(true);
+ });
+
+ it('should set noPorts flag to false when ports are available', async () => {
+ const rpgWithPorts = createMockRemoteProcessGroup('WithPorts', [createMockOutputPort('port1')]);
+ const { component } = await setup({ remoteProcessGroup: rpgWithPorts });
+ expect(component.noPorts).toBe(false);
+ });
+
+ it('should set noPorts flag to true when no ports are available', async () => {
+ const rpgWithoutPorts = createMockRemoteProcessGroup('WithoutPorts', []);
+ const { component } = await setup({ remoteProcessGroup: rpgWithoutPorts });
+ expect(component.noPorts).toBe(true);
+ });
+ });
+
+ describe('handleChanged method logic', () => {
+ it('should call callbacks when selection changes', async () => {
+ const { component } = await setup();
+ const onChangeSpy = jest.fn();
+ const onTouchedSpy = jest.fn();
+
+ component.registerOnChange(onChangeSpy);
+ component.registerOnTouched(onTouchedSpy);
+ component.selectedOutputPort = 'port1';
+
+ component.handleChanged();
+
+ expect(onChangeSpy).toHaveBeenCalledWith('port1');
+ expect(onTouchedSpy).toHaveBeenCalled();
+ expect(component.isTouched).toBe(true);
+ });
+
+ it('should handle null callbacks gracefully', async () => {
+ const { component } = await setup();
+
+ // Clear callbacks to test null handling
+ component.onChange = undefined as any;
+ component.onTouched = undefined as any;
+ component.selectedOutputPort = 'port1';
+
+ // This should not throw errors when callbacks are null
+ component.handleChanged();
+ expect(component.selectedOutputPort).toBe('port1');
+ });
+
+ it('should not call onTouched callback when already touched', async () => {
+ const { component } = await setup();
+ const onTouchedSpy = jest.fn();
+
+ component.registerOnTouched(onTouchedSpy);
+ component.isTouched = true;
+
+ component.handleChanged();
+
+ expect(onTouchedSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('ControlValueAccessor implementation', () => {
+ it('should register onChange callback', async () => {
+ const { component } = await setup();
+ const callback = jest.fn();
+ component.registerOnChange(callback);
+ expect(component.onChange).toBe(callback);
+ });
+
+ it('should register onTouched callback', async () => {
+ const { component } = await setup();
+ const callback = jest.fn();
+ component.registerOnTouched(callback);
+ expect(component.onTouched).toBe(callback);
+ });
+
+ it('should handle disabled state', async () => {
+ const { component } = await setup();
+ component.setDisabledState(true);
+ expect(component.isDisabled).toBe(true);
+
+ component.setDisabledState(false);
+ expect(component.isDisabled).toBe(false);
+ });
+
+ it('should handle writeValue', async () => {
+ const { component } = await setup();
+ component.writeValue('port1');
+ expect(component.selectedOutputPort).toBe('port1');
+ });
+
+ it('should handle writeValue with null', async () => {
+ const { component } = await setup();
+ component.writeValue(null as any);
+ expect(component.selectedOutputPort).toBeNull();
+ });
+ });
+
+ describe('Template logic', () => {
+ it('should display main container', async () => {
+ const { fixture } = await setup();
+ const container = fixture.nativeElement.querySelector('[data-qa="source-remote-process-group-container"]');
+ expect(container).toBeTruthy();
+ });
+
+ it('should display no ports error when no ports available', async () => {
+ const rpg = createMockRemoteProcessGroup('TestGroup', []);
+ const { fixture } = await setup({ remoteProcessGroup: rpg });
+
+ const noPortsSection = fixture.nativeElement.querySelector('[data-qa="no-ports-section"]');
+ const noPortsError = fixture.nativeElement.querySelector('[data-qa="no-ports-error"]');
+
+ expect(noPortsSection).toBeTruthy();
+ expect(noPortsError).toBeTruthy();
+ expect(noPortsError.textContent).toContain('TestGroup does not have any local output ports');
+ });
+
+ it('should display output port selection when ports are available', async () => {
+ const rpg = createMockRemoteProcessGroup('TestGroup', [createMockOutputPort('port1')]);
+ const { fixture } = await setup({ remoteProcessGroup: rpg });
+
+ const outputPortSection = fixture.nativeElement.querySelector('[data-qa="output-port-section"]');
+ const outputPortSelect = fixture.nativeElement.querySelector('[data-qa="output-port-select"]');
+
+ expect(outputPortSection).toBeTruthy();
+ expect(outputPortSelect).toBeTruthy();
+ });
+
+ it('should display group name correctly', async () => {
+ const rpg = createMockRemoteProcessGroup('TestGroup', []);
+ const { fixture } = await setup({ remoteProcessGroup: rpg });
+
+ const groupNameDisplay = fixture.nativeElement.querySelector('[data-qa="group-name-display"]');
+
+ expect(groupNameDisplay).toBeTruthy();
+ expect(groupNameDisplay.textContent.trim()).toBe('TestGroup');
+ });
+
+ it('should hide output port selection when no ports available', async () => {
+ const rpg = createMockRemoteProcessGroup('TestGroup', []);
+ const { fixture } = await setup({ remoteProcessGroup: rpg });
+
+ const outputPortSection = fixture.nativeElement.querySelector('[data-qa="output-port-section"]');
+
+ expect(outputPortSection).toBeFalsy();
+ });
+
+ it('should hide no ports error when ports are available', async () => {
+ const rpg = createMockRemoteProcessGroup('TestGroup', [createMockOutputPort('port1')]);
+ const { fixture } = await setup({ remoteProcessGroup: rpg });
+
+ const noPortsSection = fixture.nativeElement.querySelector('[data-qa="no-ports-section"]');
+
+ expect(noPortsSection).toBeFalsy();
+ });
});
});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-remote-process-group/source-remote-process-group.component.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-remote-process-group/source-remote-process-group.component.ts
index 4f1cdc9..d08b617 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-remote-process-group/source-remote-process-group.component.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/source/source-remote-process-group/source-remote-process-group.component.ts
@@ -100,10 +100,14 @@
// mark the component as touched if not already
if (!this.isTouched) {
this.isTouched = true;
- this.onTouched();
+ if (this.onTouched) {
+ this.onTouched();
+ }
}
// emit the changes
- this.onChange(this.selectedOutputPort);
+ if (this.onChange) {
+ this.onChange(this.selectedOutputPort);
+ }
}
}