From 61787bfcfcf69cf961614235d521ab3609a7e7eb Mon Sep 17 00:00:00 2001 From: TerranceKhumalo-absa Date: Tue, 23 Sep 2025 15:07:24 +0200 Subject: [PATCH 1/5] feat: add CPS Scheduler cypress test --- cypress/e2e/cps-scheduler.cy.ts | 293 ++++++++++++++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 cypress/e2e/cps-scheduler.cy.ts diff --git a/cypress/e2e/cps-scheduler.cy.ts b/cypress/e2e/cps-scheduler.cy.ts new file mode 100644 index 00000000..f5ab210b --- /dev/null +++ b/cypress/e2e/cps-scheduler.cy.ts @@ -0,0 +1,293 @@ +describe('CPS Scheduler Component', () => { + beforeEach(() => { + cy.visit('/scheduler/examples'); + cy.get('cps-scheduler').should('be.visible'); + }); + + /** + * Dropdown selection helper + */ + const selectDropdownOption = (selector: string, optionText: string): void => { + // Click the dropdown to open it + cy.get(selector).click(); + + // Try to find and click the option with the specified text + // Use a more generic approach that waits for any clickable element containing the text + cy.get('body').contains(optionText).should('be.visible').click(); + }; + + describe('Core Functionality', () => { + it('should display scheduler with proper initialization', () => { + cy.get('[data-cy="schedule-type-toggle"]').should('be.visible'); + cy.get('[data-cy="schedule-type-toggle"]').should( + 'contain.text', + 'Not set' + ); + }); + }); + + describe('Minutes Schedule - Cron Generation', () => { + beforeEach(() => { + cy.get('[data-cy="schedule-type-toggle"]').contains('Minutes').click(); + cy.get('[data-cy="minutes-config"]').should('be.visible'); + }); + + it('should generate cron expression for minute intervals', () => { + selectDropdownOption('[data-cy="minutes-input"]', '5'); + + // Switch to Advanced to see generated cron + cy.get('[data-cy="schedule-type-toggle"]').contains('Advanced').click(); + cy.get('[data-cy="advanced-cron-input"]') + .find('input') + .should('have.value', '0/5 * 1/1 * ? *'); + }); + + it('should generate cron expression for 15-minute intervals', () => { + selectDropdownOption('[data-cy="minutes-input"]', '15'); + + // Switch to Advanced to see generated cron + cy.get('[data-cy="schedule-type-toggle"]').contains('Advanced').click(); + cy.get('[data-cy="advanced-cron-input"]') + .find('input') + .should('have.value', '0/15 * 1/1 * ? *'); + }); + }); + + describe('Weekly Schedule - Cron Generation', () => { + beforeEach(() => { + cy.get('[data-cy="schedule-type-toggle"]').contains('Weekly').click(); + cy.get('[data-cy="weekly-config"]').should('be.visible'); + }); + + it('should generate correct cron for Monday and Wednesday', () => { + cy.get('[data-cy="weekly-MON"]').click(); + cy.get('[data-cy="weekly-WED"]').click(); + + cy.wait(1000); + + // Verify timezone selector appears + cy.get('[data-cy="timezone-selector"]').should('be.visible'); + + // Verify specific cron expression is generated + cy.get('[data-cy="schedule-type-toggle"]').contains('Advanced').click(); + cy.get('[data-cy="advanced-cron-input"]') + .find('input') + .should('have.value', '0 0 ? * MON,WED *'); + }); + + it('should generate correct cron for Friday only', () => { + // First ensure Monday is unchecked + cy.get('[data-cy="weekly-MON"]').within(() => { + cy.get('input[type="checkbox"]').uncheck({ force: true }); + }); + + // Check Friday + cy.get('[data-cy="weekly-FRI"]').within(() => { + cy.get('input[type="checkbox"]').check({ force: true }); + }); + + cy.wait(1000); + + // Verify timezone selector appears + cy.get('[data-cy="timezone-selector"]').should('be.visible'); + + // Verify specific cron expression is generated + cy.get('[data-cy="schedule-type-toggle"]').contains('Advanced').click(); + cy.get('[data-cy="advanced-cron-input"]') + .find('input') + .should('have.value', '0 0 ? * FRI *'); + }); + }); + + describe('Monthly Schedule - Cron Generation', () => { + beforeEach(() => { + cy.get('[data-cy="schedule-type-toggle"]').contains('Monthly').click(); + cy.get('[data-cy="monthly-config"]').should('be.visible'); + }); + + it('should generate correct cron for specific weekday (Second Tuesday of every month)', () => { + // Select Second week + selectDropdownOption('[data-cy="monthly-week-select"]', 'Second'); + + // Select Tuesday + selectDropdownOption('[data-cy="monthly-weekday-select"]', 'Tuesday'); + + // Select starting month as April + selectDropdownOption( + '[data-cy="monthly-weekday-start-month-select"]', + 'April' + ); + + // Verify timezone selector appears + cy.get('[data-cy="timezone-selector"]').should('be.visible'); + + // Switch to Advanced to see generated cron + cy.get('[data-cy="schedule-type-toggle"]').contains('Advanced').click(); + cy.get('[data-cy="advanced-cron-input"]') + .find('input') + .should('contain.value', '30 9 ? 4/4 TUE#2 *'); + }); + + it('should generate correct cron for specific weekday (Fourth Sunday starting in October)', () => { + // Select Fourth week + selectDropdownOption('[data-cy="monthly-week-select"]', 'Fourth'); + + // Select Sunday + selectDropdownOption('[data-cy="monthly-weekday-select"]', 'Sunday'); + + // Select starting month as October + selectDropdownOption( + '[data-cy="monthly-weekday-start-month-select"]', + 'October' + ); + + // Set custom time to 14:45 (2:45 PM) + cy.get('[data-cy="monthly-weekday-timepicker"]').within(() => { + cy.get('input').eq(1).clear().type('45'); + cy.get('input').first().clear().type('14'); + }); + + // Switch to Advanced to see generated cron + cy.get('[data-cy="schedule-type-toggle"]').contains('Advanced').click(); + cy.get('[data-cy="advanced-cron-input"]') + .find('input') + .should('contain.value', '45 14 ? 10/4 SUN#4 *'); + }); + }); + + describe('Advanced Schedule - Direct Input', () => { + beforeEach(() => { + cy.get('[data-cy="schedule-type-toggle"]').contains('Advanced').click(); + cy.get('[data-cy="advanced-config"]').should('be.visible'); + }); + + it('should accept valid cron expressions', () => { + const testCron = '0 30 14 ? * MON-FRI'; + + cy.get('[data-cy="advanced-cron-input"]') + .find('input, textarea') + .clear() + .type(testCron); + + cy.wait(1000); + + // Verify input contains the cron + cy.get('[data-cy="advanced-cron-input"]') + .find('input, textarea') + .should('have.value', testCron); + }); + + it('should handle invalid cron expressions', () => { + cy.get('[data-cy="advanced-cron-input"]') + .find('input, textarea') + .clear() + .type('invalid cron'); + + cy.wait(1000); + + // Component should not crash + cy.get('[data-cy="advanced-cron-input"]').should('be.visible'); + + // Check validation state + cy.get('body').then(($body) => { + const hasError = $body.find('.ng-invalid, .error').length > 0; + expect(hasError).to.be.true; + }); + }); + }); + + describe('State Management', () => { + it('should maintain state when switching between schedule types', () => { + // Set up Weekly schedule + cy.get('[data-cy="schedule-type-toggle"]').contains('Weekly').click(); + cy.get('[data-cy="weekly-MON"]').click(); + + cy.wait(1000); + + // Switch to Advanced to verify cron was generated + cy.get('[data-cy="schedule-type-toggle"]').contains('Advanced').click(); + cy.get('[data-cy="advanced-cron-input"]') + .find('input, textarea') + .should('not.have.value', ''); + + // Switch back to Weekly + cy.get('[data-cy="schedule-type-toggle"]').contains('Weekly').click(); + cy.get('[data-cy="weekly-config"]').should('be.visible'); + }); + + it('should reset when selecting Not set', () => { + // First set a schedule + cy.get('[data-cy="schedule-type-toggle"]').contains('Weekly').click(); + cy.get('[data-cy="weekly-MON"]').click(); + + cy.wait(1000); + + // Reset to Not set + cy.get('[data-cy="schedule-type-toggle"]').contains('Not set').click(); + + // Component should be in reset state + cy.get('cps-scheduler').should('be.visible'); + cy.get('[data-cy="schedule-type-toggle"]').should( + 'contain.text', + 'Not set' + ); + }); + }); + + describe('Timezone Functionality', () => { + it('should allow timezone filtering and show autocomplete options', () => { + cy.get('[data-cy="schedule-type-toggle"]').contains('Advanced').click(); + // Type in the autocomplete to filter timezone options + cy.get('[data-cy="timezone-select"]') + .find('input') + .clear() + .type('UTC', { force: true }); + + cy.wait(500); + + // Verify that autocomplete dropdown appears with options + cy.get('.cps-autocomplete-options, .cps-autocomplete-option').should( + 'exist' + ); + + // Verify the input field contains what we typed + cy.get('[data-cy="timezone-select"]') + .find('input') + .should('have.value', 'UTC'); + }); + + it('should maintain typed text in timezone input', () => { + cy.get('[data-cy="schedule-type-toggle"]').contains('Advanced').click(); + + // Type a specific timezone + cy.get('[data-cy="timezone-select"]') + .find('input') + .clear() + .type('Europe/London', { force: true }); + + cy.wait(500); + + // Verify the text remains in the input + cy.get('[data-cy="timezone-select"]') + .find('input') + .should('have.value', 'Europe/London'); + + // Verify timezone selector is still functional + cy.get('[data-cy="timezone-select"]').should('be.visible'); + }); + }); + + describe('Error Handling', () => { + it('should handle rapid type switching', () => { + const types = ['Minutes', 'Hourly', 'Weekly', 'Advanced'] as const; + + types.forEach((type) => { + cy.get('[data-cy="schedule-type-toggle"]').contains(type).click(); + cy.wait(200); + }); + + // Should end in valid state + cy.get('[data-cy="advanced-config"]').should('be.visible'); + }); + }); +}); From 77e02c42836f17d3b69351decabc06cb72077f31 Mon Sep 17 00:00:00 2001 From: TerranceKhumalo-absa Date: Tue, 23 Sep 2025 17:41:38 +0200 Subject: [PATCH 2/5] feat: add CronValidationService for validating AWS EventBridge Scheduler cron expressions - Implemented CronValidationService with methods to validate 6-field cron expressions. - Added support for wildcards, ranges, steps, lists, and special characters (L, W, #). - Created comprehensive unit tests for various cron expression patterns and edge cases. --- .../cps-autocomplete.component.spec.ts | 14 +- .../cps-scheduler.component.html | 144 ++- .../cps-scheduler.component.spec.ts | 828 ++++++++++++++++++ .../cps-scheduler/cps-scheduler.component.ts | 176 ++-- .../services/cron-validation.service.spec.ts | 275 ++++++ .../lib/services/cron-validation.service.ts | 462 ++++++++++ 6 files changed, 1779 insertions(+), 120 deletions(-) create mode 100644 projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.spec.ts create mode 100644 projects/cps-ui-kit/src/lib/services/cron-validation.service.spec.ts create mode 100644 projects/cps-ui-kit/src/lib/services/cron-validation.service.ts diff --git a/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.spec.ts index a8783a3c..16d51a31 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.spec.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.spec.ts @@ -1,18 +1,18 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, + discardPeriodicTasks, fakeAsync, - tick, - discardPeriodicTasks + tick } from '@angular/core/testing'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { CpsAutocompleteComponent } from './cps-autocomplete.component'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { LabelByValuePipe } from '../../pipes/internal/label-by-value.pipe'; -import { CheckOptionSelectedPipe } from '../../pipes/internal/check-option-selected.pipe'; import { By } from '@angular/platform-browser'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { CheckOptionSelectedPipe } from '../../pipes/internal/check-option-selected.pipe'; +import { LabelByValuePipe } from '../../pipes/internal/label-by-value.pipe'; import { CpsMenuHideReason } from '../cps-menu/cps-menu.component'; +import { CpsAutocompleteComponent } from './cps-autocomplete.component'; describe('CpsAutocompleteComponent', () => { let component: CpsAutocompleteComponent; diff --git a/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.html b/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.html index 24d5c461..5dec76c6 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.html @@ -1,5 +1,6 @@
-
+ [class.cps-scheduler-container-disabled]="disabled"> +
-
+
Every
-
+
Every
-
+
- +
+ [class.cps-scheduler-tab-pane-row-disabled]=" + state.daily.subTab !== 'everyDays' + "> Every day(s) at
- +
+ data-cy="daily-weekday-config" + [class.cps-scheduler-tab-pane-row-disabled]=" + state.daily.subTab !== 'everyWeekDay' + "> Every working day at
-
+
@@ -164,6 +195,7 @@ at
-
+
- +
+ [class.cps-scheduler-tab-pane-row-disabled]=" + state.monthly.subTab !== 'specificDay' + "> On the - +
+ [class.cps-scheduler-tab-pane-row-disabled]=" + state.monthly.subTab !== 'specificWeekDay' + "> On the @@ -272,6 +315,7 @@ [disabled]="disabled || state.monthly.subTab !== 'specificWeekDay'" [returnObject]="false" [hideDetails]="true" + data-cy="monthly-weekday-start-month-select" (valueChanged)="regenerateCron()" [(ngModel)]="state.monthly.specificWeekDay.startMonth" [options]="selectOptions.months" @@ -282,6 +326,7 @@ [disabled]="disabled || state.monthly.subTab !== 'specificWeekDay'" [use24HourTime]="use24HourTime" [mandatoryValue]="true" + data-cy="monthly-weekday-timepicker" [value]="formatTimeValue(state.monthly.specificWeekDay)" [hideDetails]="true" (valueChanged)=" @@ -293,19 +338,23 @@
-
+
- +
+ [class.cps-scheduler-tab-pane-row-disabled]=" + state.yearly.subTab !== 'specificMonthDay' + "> Every on the - +
+ [class.cps-scheduler-tab-pane-row-disabled]=" + state.yearly.subTab !== 'specificMonthWeek' + "> On the
-
+
-
+ +
+ [options]="timeZoneOptions"> +
diff --git a/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.spec.ts new file mode 100644 index 00000000..e8ee5d1f --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.spec.ts @@ -0,0 +1,828 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { CpsSchedulerComponent } from './cps-scheduler.component'; + +// Mock the timeZones import +jest.mock('./cps-scheduler.utils', () => ({ + timeZones: ['UTC', 'America/New_York', 'Europe/London'] +})); + +describe('CpsSchedulerComponent', () => { + let component: CpsSchedulerComponent; + let fixture: ComponentFixture; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + CpsSchedulerComponent, + ReactiveFormsModule, + NoopAnimationsModule + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(CpsSchedulerComponent); + component = fixture.componentInstance; + + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + // Set default inputs + component.cron = ''; + component.showNotSet = true; + component.showAdvanced = true; + component.showMinutes = true; + component.use24HourTime = true; + + fixture.detectChanges(); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize with default values', () => { + expect(component.activeScheduleType).toBe('Not set'); + expect(component.cron).toBe(''); + expect(component.state).toBeDefined(); + }); + + describe('Component initialization', () => { + it('should set up schedule types correctly when all options are enabled', () => { + component.showNotSet = true; + component.showAdvanced = true; + component.showMinutes = true; + component.ngOnInit(); + + expect(component.scheduleTypes).toEqual([ + { label: 'Not set', value: 'Not set' }, + { label: 'Minutes', value: 'Minutes' }, + { label: 'Hourly', value: 'Hourly' }, + { label: 'Daily', value: 'Daily' }, + { label: 'Weekly', value: 'Weekly' }, + { label: 'Monthly', value: 'Monthly' }, + { label: 'Yearly', value: 'Yearly' }, + { label: 'Advanced', value: 'Advanced' } + ]); + }); + + it('should remove Advanced option when showAdvanced is false', () => { + component.showAdvanced = false; + component.ngOnInit(); + + const hasAdvanced = component.scheduleTypes.some( + (type) => type.value === 'Advanced' + ); + expect(hasAdvanced).toBe(false); + }); + + it('should remove Minutes option when showMinutes is false', () => { + component.showMinutes = false; + component.ngOnInit(); + + const hasMinutes = component.scheduleTypes.some( + (type) => type.value === 'Minutes' + ); + expect(hasMinutes).toBe(false); + }); + + it('should remove Not set option and set default when showNotSet is false', () => { + component.showNotSet = false; + component.cron = ''; + component.ngOnInit(); + + const hasNotSet = component.scheduleTypes.some( + (type) => type.value === 'Not set' + ); + expect(hasNotSet).toBe(false); + expect(component.cron).toBe('0/1 * 1/1 * ? *'); + }); + }); + + describe('_handleModelChange method', () => { + beforeEach(() => { + // Reset isDirty flag before each test + (component as any)._isDirty = false; + }); + + describe('Invalid cron expressions', () => { + it('should handle non-string cron values', () => { + (component as any)._handleModelChange(null); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Invalid cron value:', + null + ); + expect(component.cron).toBe(''); + }); + + it('should handle empty cron when showNotSet is false', () => { + component.showNotSet = false; + (component as any)._handleModelChange(''); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Invalid cron value:', ''); + expect(component.cron).toBe('0/1 * 1/1 * ? *'); + }); + + it('should handle cron with wrong number of parts', () => { + (component as any)._handleModelChange('0 1 2'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Invalid cron value:', + '0 1 2' + ); + expect(component.cron).toBe(''); + }); + + it('should allow empty cron when showNotSet is true', () => { + component.showNotSet = true; + (component as any)._handleModelChange(''); + + expect(component.activeScheduleType).toBe('Not set'); + }); + }); + + describe('Dirty flag handling', () => { + it('should return early when _isDirty is true', () => { + (component as any)._isDirty = true; + const initialActiveType = component.activeScheduleType; + + (component as any)._handleModelChange('0 12 * * ? *'); + + expect(component.activeScheduleType).toBe(initialActiveType); + expect((component as any)._isDirty).toBe(false); + }); + + it('should process when _isDirty is false', () => { + (component as any)._isDirty = false; + + (component as any)._handleModelChange('0/5 * 1/1 * ? *'); + + expect(component.activeScheduleType).toBe('Minutes'); + }); + }); + + describe('Minutes pattern parsing', () => { + it('should parse minutes cron expression with 1/1 pattern', () => { + (component as any)._handleModelChange('0/5 * 1/1 * ? *'); + + expect(component.activeScheduleType).toBe('Minutes'); + expect(component.state.minutes.minutes).toBe(5); + }); + + it('should parse single digit minutes', () => { + (component as any)._handleModelChange('0/1 * 1/1 * ? *'); + + expect(component.activeScheduleType).toBe('Minutes'); + expect(component.state.minutes.minutes).toBe(1); + }); + + it('should parse double digit minutes', () => { + (component as any)._handleModelChange('0/15 * 1/1 * ? *'); + + expect(component.activeScheduleType).toBe('Minutes'); + expect(component.state.minutes.minutes).toBe(15); + }); + }); + + describe('Hourly pattern parsing', () => { + it('should parse hourly cron expression with 1/1 pattern', () => { + (component as any)._handleModelChange('30 0/2 1/1 * ? *'); + + expect(component.activeScheduleType).toBe('Hourly'); + expect(component.state.hourly.hours).toBe(2); + expect(component.state.hourly.minutes).toBe(30); + }); + + it('should parse hourly with single digit hours', () => { + (component as any)._handleModelChange('15 0/1 1/1 * ? *'); + + expect(component.activeScheduleType).toBe('Hourly'); + expect(component.state.hourly.hours).toBe(1); + expect(component.state.hourly.minutes).toBe(15); + }); + }); + + describe('Daily pattern parsing', () => { + it('should parse every N days pattern', () => { + (component as any)._handleModelChange('15 9 1/3 * ? *'); + + expect(component.activeScheduleType).toBe('Daily'); + expect(component.state.daily.subTab).toBe('everyDays'); + expect(component.state.daily.everyDays.days).toBe(3); + expect(component.state.daily.everyDays.hours).toBe(9); + expect(component.state.daily.everyDays.minutes).toBe(15); + }); + + it('should parse every single day pattern', () => { + (component as any)._handleModelChange('0 8 1/1 * ? *'); + + expect(component.activeScheduleType).toBe('Daily'); + expect(component.state.daily.subTab).toBe('everyDays'); + expect(component.state.daily.everyDays.days).toBe(1); + expect(component.state.daily.everyDays.hours).toBe(8); + expect(component.state.daily.everyDays.minutes).toBe(0); + }); + + it('should parse weekday pattern', () => { + (component as any)._handleModelChange('0 8 ? * MON-FRI *'); + + expect(component.activeScheduleType).toBe('Daily'); + expect(component.state.daily.subTab).toBe('everyWeekDay'); + expect(component.state.daily.everyWeekDay.hours).toBe(8); + expect(component.state.daily.everyWeekDay.minutes).toBe(0); + }); + + it('should handle 12-hour time format for daily pattern', () => { + component.use24HourTime = false; + (component as any)._handleModelChange('0 14 1/1 * ? *'); + + expect(component.activeScheduleType).toBe('Daily'); + expect(component.state.daily.everyDays.hours).toBe(2); + expect(component.state.daily.everyDays.hourType).toBe('PM'); + }); + }); + + describe('Weekly pattern parsing', () => { + it('should parse single day weekly pattern', () => { + (component as any)._handleModelChange('0 10 ? * MON *'); + + expect(component.activeScheduleType).toBe('Weekly'); + expect(component.state.weekly.MON).toBe(true); + expect(component.state.weekly.TUE).toBe(false); + expect(component.state.weekly.hours).toBe(10); + expect(component.state.weekly.minutes).toBe(0); + }); + + it('should parse multiple days weekly pattern', () => { + (component as any)._handleModelChange('30 14 ? * MON,WED,FRI *'); + + expect(component.activeScheduleType).toBe('Weekly'); + expect(component.state.weekly.MON).toBe(true); + expect(component.state.weekly.TUE).toBe(false); + expect(component.state.weekly.WED).toBe(true); + expect(component.state.weekly.THU).toBe(false); + expect(component.state.weekly.FRI).toBe(true); + expect(component.state.weekly.SAT).toBe(false); + expect(component.state.weekly.SUN).toBe(false); + }); + + it('should reset all days before setting new ones', () => { + // First set some days manually + component.state.weekly = { + MON: false, + TUE: true, + WED: false, + THU: true, + FRI: false, + SAT: true, + SUN: false, + hours: 12, + minutes: 0, + hourType: undefined + }; + + (component as any)._handleModelChange('0 12 ? * MON,FRI *'); + + expect(component.state.weekly.MON).toBe(true); + expect(component.state.weekly.TUE).toBe(false); + expect(component.state.weekly.WED).toBe(false); + expect(component.state.weekly.THU).toBe(false); + expect(component.state.weekly.FRI).toBe(true); + expect(component.state.weekly.SAT).toBe(false); + expect(component.state.weekly.SUN).toBe(false); + }); + }); + + describe('Monthly pattern parsing', () => { + it('should parse specific day monthly pattern', () => { + (component as any)._handleModelChange('0 12 15 1/2 ? *'); + + expect(component.activeScheduleType).toBe('Monthly'); + expect(component.state.monthly.subTab).toBe('specificDay'); + expect(component.state.monthly.specificDay.day).toBe('15'); + expect(component.state.monthly.specificDay.months).toBe(2); + expect(component.state.monthly.runOnWeekday).toBe(false); + }); + + it('should parse weekday monthly pattern', () => { + (component as any)._handleModelChange('0 12 1W 1/1 ? *'); + + expect(component.activeScheduleType).toBe('Monthly'); + expect(component.state.monthly.specificDay.day).toBe('1'); + expect(component.state.monthly.runOnWeekday).toBe(true); + }); + + it('should parse specific week day monthly pattern', () => { + (component as any)._handleModelChange('0 10 ? 2/3 MON#2 *'); + + expect(component.activeScheduleType).toBe('Monthly'); + expect(component.state.monthly.subTab).toBe('specificWeekDay'); + expect(component.state.monthly.specificWeekDay.day).toBe('MON'); + expect(component.state.monthly.specificWeekDay.monthWeek).toBe('#2'); + expect(component.state.monthly.specificWeekDay.startMonth).toBe(2); + expect(component.state.monthly.specificWeekDay.months).toBe(3); + }); + + it('should parse last occurrence monthly pattern', () => { + (component as any)._handleModelChange('0 15 ? 1/1 FRIL *'); + + expect(component.activeScheduleType).toBe('Monthly'); + expect(component.state.monthly.subTab).toBe('specificWeekDay'); + expect(component.state.monthly.specificWeekDay.day).toBe('FRI'); + expect(component.state.monthly.specificWeekDay.monthWeek).toBe('L'); + }); + + it('should handle last day of month pattern', () => { + (component as any)._handleModelChange('0 12 L 1/1 ? *'); + + expect(component.activeScheduleType).toBe('Monthly'); + expect(component.state.monthly.specificDay.day).toBe('L'); + }); + }); + + describe('Yearly pattern parsing', () => { + it('should parse specific month day yearly pattern', () => { + (component as any)._handleModelChange('0 9 25 12 ? *'); + + expect(component.activeScheduleType).toBe('Yearly'); + expect(component.state.yearly.subTab).toBe('specificMonthDay'); + expect(component.state.yearly.specificMonthDay.day).toBe('25'); + expect(component.state.yearly.specificMonthDay.month).toBe(12); + expect(component.state.yearly.runOnWeekday).toBe(false); + }); + + it('should parse weekday yearly pattern', () => { + (component as any)._handleModelChange('0 9 1W 6 ? *'); + + expect(component.activeScheduleType).toBe('Yearly'); + expect(component.state.yearly.specificMonthDay.day).toBe('1'); + expect(component.state.yearly.runOnWeekday).toBe(true); + }); + + it('should parse specific month week yearly pattern', () => { + (component as any)._handleModelChange('0 10 ? 11 THU#4 *'); + + expect(component.activeScheduleType).toBe('Yearly'); + expect(component.state.yearly.subTab).toBe('specificMonthWeek'); + expect(component.state.yearly.specificMonthWeek.day).toBe('THU'); + expect(component.state.yearly.specificMonthWeek.monthWeek).toBe('#4'); + expect(component.state.yearly.specificMonthWeek.month).toBe(11); + }); + }); + + describe('Advanced pattern parsing', () => { + it('should set advanced mode for truly unrecognized patterns', () => { + // Use a pattern that doesn't match any of the existing regex patterns + const customCron = '5 10 15 * * ?'; + (component as any)._handleModelChange(customCron); + + expect(component.activeScheduleType).toBe('Advanced'); + expect(component.form.controls.advanced.value).toBe(customCron); + }); + + it('should handle complex custom expressions with wildcards', () => { + // Pattern that uses wildcards in a way that doesn't match standard patterns + const complexCron = '*/15 10-12 * * 1-5 *'; + (component as any)._handleModelChange(complexCron); + + expect(component.activeScheduleType).toBe('Advanced'); + expect(component.form.controls.advanced.value).toBe(complexCron); + }); + + it('should handle invalid but 6-part expressions', () => { + const invalidCron = '99 99 99 99 99 99'; + (component as any)._handleModelChange(invalidCron); + + expect(component.activeScheduleType).toBe('Advanced'); + expect(component.form.controls.advanced.value).toBe(invalidCron); + }); + + it('should handle cron with ranges that do not match standard patterns', () => { + const rangeCron = '0 8-17 * * 1,3,5 *'; + (component as any)._handleModelChange(rangeCron); + + expect(component.activeScheduleType).toBe('Advanced'); + expect(component.form.controls.advanced.value).toBe(rangeCron); + }); + + it('should handle cron with step values in non-standard positions', () => { + const stepCron = '0 */3 */2 * * ?'; + (component as any)._handleModelChange(stepCron); + + expect(component.activeScheduleType).toBe('Advanced'); + expect(component.form.controls.advanced.value).toBe(stepCron); + }); + + it('should handle cron with specific numeric values that do not match patterns', () => { + const numericCron = '45 22 5,15,25 * * *'; + (component as any)._handleModelChange(numericCron); + + expect(component.activeScheduleType).toBe('Advanced'); + expect(component.form.controls.advanced.value).toBe(numericCron); + }); + + it('should handle cron expressions with question marks in different positions', () => { + const questionCron = '0 12 ? 6 * 2024'; + (component as any)._handleModelChange(questionCron); + + expect(component.activeScheduleType).toBe('Advanced'); + expect(component.form.controls.advanced.value).toBe(questionCron); + }); + + it('should handle expressions with mixed separators', () => { + const mixedCron = '0 9 1-15 JAN-DEC ? *'; + (component as any)._handleModelChange(mixedCron); + + expect(component.activeScheduleType).toBe('Advanced'); + expect(component.form.controls.advanced.value).toBe(mixedCron); + }); + }); + + describe('Time format handling', () => { + it('should handle 24-hour format correctly', () => { + component.use24HourTime = true; + (component as any)._handleModelChange('0 23 ? * MON *'); + + expect(component.activeScheduleType).toBe('Weekly'); + expect(component.state.weekly.hours).toBe(23); + expect(component.state.weekly.hourType).toBeUndefined(); + }); + + it('should convert to 12-hour format when use24HourTime is false', () => { + component.use24HourTime = false; + (component as any)._handleModelChange('0 15 ? * MON *'); + + expect(component.activeScheduleType).toBe('Weekly'); + expect(component.state.weekly.hours).toBe(3); + expect(component.state.weekly.hourType).toBe('PM'); + }); + + it('should handle midnight in 12-hour format', () => { + component.use24HourTime = false; + (component as any)._handleModelChange('0 0 ? * MON *'); + + expect(component.activeScheduleType).toBe('Weekly'); + expect(component.state.weekly.hours).toBe(12); + expect(component.state.weekly.hourType).toBe('AM'); + }); + + it('should handle noon in 12-hour format', () => { + component.use24HourTime = false; + (component as any)._handleModelChange('0 12 ? * MON *'); + + expect(component.activeScheduleType).toBe('Weekly'); + expect(component.state.weekly.hours).toBe(12); + expect(component.state.weekly.hourType).toBe('PM'); + }); + }); + + describe('Edge cases and regex matching', () => { + it('should handle minutes pattern with different formats', () => { + (component as any)._handleModelChange('0/30 * 1/1 * ? *'); + expect(component.activeScheduleType).toBe('Minutes'); + expect(component.state.minutes.minutes).toBe(30); + }); + + it('should handle hourly pattern with different minute values', () => { + (component as any)._handleModelChange('45 0/3 1/1 * ? *'); + expect(component.activeScheduleType).toBe('Hourly'); + expect(component.state.hourly.hours).toBe(3); + expect(component.state.hourly.minutes).toBe(45); + }); + + it('should handle daily pattern with large day intervals', () => { + (component as any)._handleModelChange('30 15 1/7 * ? *'); + expect(component.activeScheduleType).toBe('Daily'); + expect(component.state.daily.everyDays.days).toBe(7); + }); + + it('should handle all weekdays in weekly pattern', () => { + (component as any)._handleModelChange( + '0 9 ? * MON,TUE,WED,THU,FRI,SAT,SUN *' + ); + expect(component.activeScheduleType).toBe('Weekly'); + expect(component.state.weekly.MON).toBe(true); + expect(component.state.weekly.TUE).toBe(true); + expect(component.state.weekly.WED).toBe(true); + expect(component.state.weekly.THU).toBe(true); + expect(component.state.weekly.FRI).toBe(true); + expect(component.state.weekly.SAT).toBe(true); + expect(component.state.weekly.SUN).toBe(true); + }); + }); + }); + + describe('Form validation', () => { + it('should validate advanced cron expressions', () => { + const advancedControl = component.form.controls.advanced; + + // Valid cron + advancedControl.setValue('0 12 1 1 ? *'); + expect(advancedControl.errors).toBeNull(); + + // Invalid cron + advancedControl.setValue('invalid cron'); + expect(advancedControl.errors).toEqual({ + invalidExpression: 'Invalid cron expression format' + }); + + // Empty value should be valid + advancedControl.setValue(''); + expect(advancedControl.errors).toBeNull(); + }); + + it('should validate cron with wrong number of parts', () => { + const advancedControl = component.form.controls.advanced; + + advancedControl.setValue('0 12 1'); + expect(advancedControl.errors).toEqual({ + invalidExpression: 'Invalid cron expression format' + }); + }); + + it('should require advanced field when Advanced tab is selected', () => { + component.setActiveScheduleType('Advanced'); + const advancedControl = component.form.controls.advanced; + + advancedControl.setValue(''); + expect(advancedControl.hasError('required')).toBe(true); + + advancedControl.setValue('0 12 1 1 ? *'); + expect(advancedControl.hasError('required')).toBe(false); + }); + + it('should remove required validator when not in Advanced mode', () => { + // First set to Advanced to add required validator + component.setActiveScheduleType('Advanced'); + let advancedControl = component.form.controls.advanced; + expect(advancedControl.hasError('required')).toBe(true); + + // Then switch to another tab + component.setActiveScheduleType('Daily'); + advancedControl = component.form.controls.advanced; + expect(advancedControl.errors).toBeNull(); + }); + }); + + describe('Event emission', () => { + it('should emit cronChange when cron value changes', () => { + jest.spyOn(component.cronChange, 'emit'); + + component.setActiveScheduleType('Minutes'); + component.state.minutes.minutes = 10; + component.regenerateCron(); + + expect(component.cronChange.emit).toHaveBeenCalledWith( + '0/10 * 1/1 * ? *' + ); + }); + + it('should emit timeZoneChange when timezone changes', () => { + jest.spyOn(component.timeZoneChange, 'emit'); + component.showTimeZone = true; + + component.onTimeZoneChanged('America/New_York'); + + expect(component.timeZoneChange.emit).toHaveBeenCalledWith( + 'America/New_York' + ); + }); + + it('should not emit timeZoneChange when showTimeZone is false', () => { + jest.spyOn(component.timeZoneChange, 'emit'); + component.showTimeZone = false; + + component.onTimeZoneChanged('America/New_York'); + + expect(component.timeZoneChange.emit).not.toHaveBeenCalled(); + }); + + it('should not emit cronChange when value is same', () => { + jest.spyOn(component.cronChange, 'emit'); + component.cron = '0/5 * 1/1 * ? *'; + + (component as any)._updateCron('0/5 * 1/1 * ? *'); + + expect(component.cronChange.emit).not.toHaveBeenCalled(); + }); + }); + + describe('Helper methods', () => { + it('should format time values correctly', () => { + const timeValue = { hours: 9, minutes: 5 }; + const formatted = component.formatTimeValue(timeValue); + + expect(formatted).toEqual({ hours: '09', minutes: '05' }); + }); + + it('should format time values with double digits', () => { + const timeValue = { hours: 23, minutes: 45 }; + const formatted = component.formatTimeValue(timeValue); + + expect(formatted).toEqual({ hours: '23', minutes: '45' }); + }); + + it('should handle time changes', () => { + const timeValue = { hours: '14', minutes: '30' }; + const target = { hours: 0, minutes: 0 }; + + jest.spyOn(component, 'regenerateCron'); + + component.onTimeChanged(timeValue, target); + + expect(target.hours).toBe(14); + expect(target.minutes).toBe(30); + expect(component.regenerateCron).toHaveBeenCalled(); + }); + + it('should handle invalid time strings', () => { + const timeValue = { hours: 'invalid', minutes: 'invalid' }; + const target = { hours: 5, minutes: 10 }; + + component.onTimeChanged(timeValue, target); + + expect(target.hours).toBe(0); + expect(target.minutes).toBe(0); + }); + + it('should convert hour to cron format in 24-hour mode', () => { + component.use24HourTime = true; + const result = (component as any)._hourToCron(15, 'PM'); + expect(result).toBe(15); + }); + + it('should convert hour to cron format in 12-hour mode', () => { + component.use24HourTime = false; + expect((component as any)._hourToCron(2, 'PM')).toBe(14); + expect((component as any)._hourToCron(12, 'PM')).toBe(12); + expect((component as any)._hourToCron(12, 'AM')).toBe(0); + expect((component as any)._hourToCron(1, 'AM')).toBe(1); + }); + + it('should get AM/PM hour correctly', () => { + component.use24HourTime = false; + expect((component as any)._getAmPmHour(0)).toBe(12); + expect((component as any)._getAmPmHour(12)).toBe(12); + expect((component as any)._getAmPmHour(15)).toBe(3); + expect((component as any)._getAmPmHour(23)).toBe(11); + }); + + it('should get hour type correctly', () => { + component.use24HourTime = false; + expect((component as any)._getHourType(0)).toBe('AM'); + expect((component as any)._getHourType(11)).toBe('AM'); + expect((component as any)._getHourType(12)).toBe('PM'); + expect((component as any)._getHourType(23)).toBe('PM'); + }); + + it('should return undefined hour type in 24-hour mode', () => { + component.use24HourTime = true; + expect((component as any)._getHourType(15)).toBeUndefined(); + }); + }); + + describe('Component properties', () => { + it('should have correct default values', () => { + expect(component.label).toBe(''); + expect(component.timeZone).toBe( + Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' + ); + expect(component.showNotSet).toBe(true); + expect(component.showAdvanced).toBe(true); + expect(component.showMinutes).toBe(true); + expect(component.showTimeZone).toBe(false); + expect(component.defaultTime).toBe('00:00'); + expect(component.use24HourTime).toBe(true); + expect(component.disabled).toBe(false); + }); + + it('should have correct select options structure', () => { + expect(component.selectOptions).toBeDefined(); + expect(component.selectOptions.months).toBeDefined(); + expect(component.selectOptions.days).toBeDefined(); + expect(component.selectOptions.hours).toBeDefined(); + expect(component.selectOptions.minutes).toBeDefined(); + expect(component.selectOptions.monthDays).toBeDefined(); + expect(component.selectOptions.monthWeeks).toBeDefined(); + }); + + it('should have correct timezone options', () => { + expect(component.timeZoneOptions).toEqual([ + { label: 'UTC', value: 'UTC' }, + { label: 'America/New_York', value: 'America/New_York' }, + { label: 'Europe/London', value: 'Europe/London' } + ]); + }); + + it('should have correct month day labels', () => { + expect((component as any)._getMonthDayLabel('1')).toBe('1st day'); + expect((component as any)._getMonthDayLabel('2')).toBe('2nd day'); + expect((component as any)._getMonthDayLabel('3')).toBe('3rd day'); + expect((component as any)._getMonthDayLabel('4')).toBe('4th day'); + expect((component as any)._getMonthDayLabel('11')).toBe('11th day'); + expect((component as any)._getMonthDayLabel('21')).toBe('21st day'); + expect((component as any)._getMonthDayLabel('L')).toBe('Last Day'); + }); + }); + + describe('Cron regeneration', () => { + it('should not change cron when disabled', () => { + component.disabled = true; + const initialCron = component.cron; + + component.setActiveScheduleType('Minutes'); + + expect(component.cron).toBe(initialCron); + }); + + it('should handle tab change parameter', () => { + component.setActiveScheduleType('Advanced'); + component.form.controls.advanced.setValue('0 12 * * ? *'); + + component.regenerateCron(true); + + expect(component.form.controls.advanced.value).toBe(component.cron); + }); + + it('should throw error for invalid daily subtab', () => { + component.setActiveScheduleType('Daily'); + component.state.daily.subTab = 'invalid'; + + expect(() => component.regenerateCron()).toThrow( + 'Invalid cron daily subtab selection' + ); + }); + + it('should throw error for invalid monthly subtab', () => { + component.setActiveScheduleType('Monthly'); + component.state.monthly.subTab = 'invalid'; + + expect(() => component.regenerateCron()).toThrow( + 'Invalid cron monthly subtab selection' + ); + }); + + it('should throw error for invalid yearly subtab', () => { + component.setActiveScheduleType('Yearly'); + component.state.yearly.subTab = 'invalid'; + + expect(() => component.regenerateCron()).toThrow( + 'Invalid cron yearly subtab selection' + ); + }); + + it('should throw error for invalid cron type when showNotSet is false', () => { + component.showNotSet = false; + component.activeScheduleType = 'Invalid'; + + expect(() => component.regenerateCron()).toThrow('Invalid cron type'); + }); + }); + + describe('ngOnChanges', () => { + it('should handle cron changes after first change', () => { + jest.spyOn(component as any, '_handleModelChange'); + + const changes = { + value: { + currentValue: '0/5 * 1/1 * ? *', + previousValue: '', + firstChange: false, + isFirstChange: () => false + } + }; + + component.ngOnChanges(changes); + + expect((component as any)._handleModelChange).toHaveBeenCalledWith( + component.cron + ); + }); + + it('should not handle first change', () => { + jest.spyOn(component as any, '_handleModelChange'); + + const changes = { + value: { + currentValue: '0/5 * 1/1 * ? *', + previousValue: undefined, + firstChange: true, + isFirstChange: () => true + } + }; + + component.ngOnChanges(changes); + + expect((component as any)._handleModelChange).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.ts b/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.ts index 31b82dba..7b33f306 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.ts @@ -1,14 +1,15 @@ +import { CommonModule } from '@angular/common'; import { + ChangeDetectionStrategy, + ChangeDetectorRef, Component, - OnInit, - OnChanges, + EventEmitter, Input, + OnChanges, + OnInit, Output, - EventEmitter, - SimpleChanges, - ChangeDetectorRef + SimpleChanges } from '@angular/core'; -import { CommonModule } from '@angular/common'; import { FormBuilder, FormControl, @@ -17,21 +18,22 @@ import { ReactiveFormsModule, Validators } from '@angular/forms'; -import { timeZones } from './cps-scheduler.utils'; -import { CpsSelectComponent } from '../cps-select/cps-select.component'; -import { CpsRadioGroupComponent } from '../cps-radio-group/cps-radio-group.component'; -import { CpsRadioComponent } from '../cps-radio-group/cps-radio/cps-radio.component'; -import { CpsCheckboxComponent } from '../cps-checkbox/cps-checkbox.component'; -import { CpsInputComponent } from '../cps-input/cps-input.component'; +import { CronValidationService } from '../../services/cron-validation.service'; import { CpsAutocompleteComponent } from '../cps-autocomplete/cps-autocomplete.component'; import { CpsButtonToggleComponent, CpsButtonToggleOption } from '../cps-button-toggle/cps-button-toggle.component'; +import { CpsCheckboxComponent } from '../cps-checkbox/cps-checkbox.component'; +import { CpsInputComponent } from '../cps-input/cps-input.component'; +import { CpsRadioGroupComponent } from '../cps-radio-group/cps-radio-group.component'; +import { CpsRadioComponent } from '../cps-radio-group/cps-radio/cps-radio.component'; +import { CpsSelectComponent } from '../cps-select/cps-select.component'; import { - CpsTimepickerComponent, - CpsTime + CpsTime, + CpsTimepickerComponent } from '../cps-timepicker/cps-timepicker.component'; +import { timeZones } from './cps-scheduler.utils'; const Days = { MON: 'Monday', @@ -86,6 +88,7 @@ enum Months { CpsTimepickerComponent, CpsAutocompleteComponent ], + changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './cps-scheduler.component.html', styleUrl: './cps-scheduler.component.scss' }) @@ -186,16 +189,16 @@ export class CpsSchedulerComponent implements OnInit, OnChanges { timeZoneOptions = timeZones.map((tz) => ({ label: tz, value: tz })); state: any; form: FormGroup = this._fb.group({ - advanced: ['', [this._validateAdvancedExpr]] + advanced: ['', [this._validateAdvancedExpr.bind(this)]] }); private _isDirty = false; private _minutesDefault = '0/1 * 1/1 * ? *'; - // eslint-disable-next-line no-useless-constructor constructor( private _fb: FormBuilder, - private _cdr: ChangeDetectorRef + private _cdr: ChangeDetectorRef, + private _cronValidationService: CronValidationService ) {} ngOnInit(): void { @@ -392,7 +395,15 @@ export class CpsSchedulerComponent implements OnInit, OnChanges { this.cronChange.emit(this.cron); } - private _isValidCron(cron: string): boolean { + /** + * Basic structural validation for pattern recognition. + * More permissive than _isValidCron to allow unrecognized patterns + * to reach Advanced mode (preserving original behavior). + * + * @param cron - The cron expression to validate structurally + * @returns boolean - True if the structure is valid for pattern parsing + */ + private _isValidCronStructure(cron: string): boolean { if (typeof cron !== 'string') return false; if (!this.showNotSet) { @@ -407,32 +418,27 @@ export class CpsSchedulerComponent implements OnInit, OnChanges { return true; } - private _validateAdvancedExpr(c: FormControl) { + /** + * Validates a cron expression using the CronValidationService. + * + * @param cron - The cron expression to validate + * @returns boolean - True if the cron expression is valid + */ + private _isValidCron(cron: string): boolean { + return this._cronValidationService.isValidCron(cron, this.showNotSet); + } + + private _validateAdvancedExpr( + c: FormControl + ): { invalidExpression: string } | null { const cron = c.value; if (!cron) return null; - const splits = cron?.split(' ') || []; - if (splits.length !== 6) return { invalidExpression: 'Invalid expression' }; - - const cronSeven = `0 ${cron}`; + if (!this._isValidCron(cron)) { + return { invalidExpression: 'Invalid cron expression format' }; + } - return cronSeven.match(/\d+ 0\/\d+ \* 1\/1 \* \? \*/) || - cronSeven.match(/\d+ \d+ 0\/\d+ 1\/1 \* \? \*/) || - cronSeven.match(/\d+ \d+ \d+ 1\/\d+ \* \? \*/) || - cronSeven.match(/\d+ \d+ \d+ \? \* MON-FRI \*/) || - cronSeven.match( - /\d+ \d+ \d+ \? \* (MON|TUE|WED|THU|FRI|SAT|SUN)(,(MON|TUE|WED|THU|FRI|SAT|SUN))* \*/ - ) || - cronSeven.match(/\d+ \d+ \d+ (\d+|L|LW|1W) 1\/\d+ \? \*/) || - cronSeven.match( - /\d+ \d+ \d+ \? \d+\/\d+ (MON|TUE|WED|THU|FRI|SAT|SUN)((#[1-5])|L) \*/ - ) || - cronSeven.match(/\d+ \d+ \d+ (\d+|L|LW|1W) \d+ \? \*/) || - cronSeven.match( - /\d+ \d+ \d+ \? \d+ (MON|TUE|WED|THU|FRI|SAT|SUN)((#[1-5])|L) \*/ - ) - ? null - : { invalidExpression: 'Invalid expression' }; + return null; } private _getAmPmHour(hour: number): number { @@ -458,7 +464,7 @@ export class CpsSchedulerComponent implements OnInit, OnChanges { } private _handleModelChange(cron: string): void { - if (!this._isValidCron(cron)) { + if (!this._isValidCronStructure(cron)) { console.error('Invalid cron value:', cron); this.cron = this.showNotSet ? '' : this._minutesDefault; return; @@ -478,40 +484,31 @@ export class CpsSchedulerComponent implements OnInit, OnChanges { } const cronSeven = `0 ${cron}`; - const [minutes, hours, dayOfMonth, month, dayOfWeek] = cron.split(' '); - if (cronSeven.match(/\d+ 0\/\d+ \* 1\/1 \* \? \*/)) { + if (this._isMinutesPattern(cronSeven)) { this.activeScheduleType = 'Minutes'; - this.state.minutes.minutes = Number(minutes.substring(2)); - } else if (cronSeven.match(/\d+ \d+ 0\/\d+ 1\/1 \* \? \*/)) { + } else if (this._isHourlyPattern(cronSeven)) { this.activeScheduleType = 'Hourly'; - this.state.hourly.hours = Number(hours.substring(2)); this.state.hourly.minutes = Number(minutes); - } else if (cronSeven.match(/\d+ \d+ \d+ 1\/\d+ \* \? \*/)) { + } else if (this._isDailyEveryDaysPattern(cronSeven)) { this.activeScheduleType = 'Daily'; - this.state.daily.subTab = 'everyDays'; this.state.daily.everyDays.days = Number(dayOfMonth.substring(2)); const parsedHours = Number(hours); this.state.daily.everyDays.hours = this._getAmPmHour(parsedHours); this.state.daily.everyDays.hourType = this._getHourType(parsedHours); this.state.daily.everyDays.minutes = Number(minutes); - } else if (cronSeven.match(/\d+ \d+ \d+ \? \* MON-FRI \*/)) { + } else if (this._isDailyWeekdayPattern(cronSeven)) { this.activeScheduleType = 'Daily'; - this.state.daily.subTab = 'everyWeekDay'; const parsedHours = Number(hours); this.state.daily.everyWeekDay.hours = this._getAmPmHour(parsedHours); this.state.daily.everyWeekDay.hourType = this._getHourType(parsedHours); this.state.daily.everyWeekDay.minutes = Number(minutes); - } else if ( - cronSeven.match( - /\d+ \d+ \d+ \? \* (MON|TUE|WED|THU|FRI|SAT|SUN)(,(MON|TUE|WED|THU|FRI|SAT|SUN))* \*/ - ) - ) { + } else if (this._isWeeklyPattern(cronSeven)) { this.activeScheduleType = 'Weekly'; this.selectOptions.days .map((d) => d.value) @@ -523,12 +520,12 @@ export class CpsSchedulerComponent implements OnInit, OnChanges { this.state.weekly.hours = this._getAmPmHour(parsedHours); this.state.weekly.hourType = this._getHourType(parsedHours); this.state.weekly.minutes = Number(minutes); - } else if (cronSeven.match(/\d+ \d+ \d+ (\d+|L|LW|1W) 1\/\d+ \? \*/)) { + } else if (this._isMonthlySpecificDayPattern(cronSeven)) { this.activeScheduleType = 'Monthly'; this.state.monthly.subTab = 'specificDay'; - if (dayOfMonth.indexOf('W') !== -1) { - this.state.monthly.specificDay.day = dayOfMonth.charAt(0); + if (dayOfMonth.includes('W')) { + this.state.monthly.specificDay.day = dayOfMonth.replace('W', ''); this.state.monthly.runOnWeekday = true; } else { this.state.monthly.specificDay.day = dayOfMonth; @@ -539,11 +536,7 @@ export class CpsSchedulerComponent implements OnInit, OnChanges { this.state.monthly.specificDay.hours = this._getAmPmHour(parsedHours); this.state.monthly.specificDay.hourType = this._getHourType(parsedHours); this.state.monthly.specificDay.minutes = Number(minutes); - } else if ( - cronSeven.match( - /\d+ \d+ \d+ \? \d+\/\d+ (MON|TUE|WED|THU|FRI|SAT|SUN)((#[1-5])|L) \*/ - ) - ) { + } else if (this._isMonthlySpecificWeekDayPattern(cronSeven)) { const day = dayOfWeek.substring(0, 3); const monthWeek = dayOfWeek.substring(3); this.activeScheduleType = 'Monthly'; @@ -551,7 +544,7 @@ export class CpsSchedulerComponent implements OnInit, OnChanges { this.state.monthly.specificWeekDay.monthWeek = monthWeek; this.state.monthly.specificWeekDay.day = day; - if (month.indexOf('/') !== -1) { + if (month.includes('/')) { const [startMonth, months] = month.split('/').map(Number); this.state.monthly.specificWeekDay.months = months; this.state.monthly.specificWeekDay.startMonth = startMonth; @@ -562,13 +555,13 @@ export class CpsSchedulerComponent implements OnInit, OnChanges { this.state.monthly.specificWeekDay.hourType = this._getHourType(parsedHours); this.state.monthly.specificWeekDay.minutes = Number(minutes); - } else if (cronSeven.match(/\d+ \d+ \d+ (\d+|L|LW|1W) \d+ \? \*/)) { + } else if (this._isYearlySpecificMonthDayPattern(cronSeven)) { this.activeScheduleType = 'Yearly'; this.state.yearly.subTab = 'specificMonthDay'; this.state.yearly.specificMonthDay.month = Number(month); - if (dayOfMonth.indexOf('W') !== -1) { - this.state.yearly.specificMonthDay.day = dayOfMonth.charAt(0); + if (dayOfMonth.includes('W')) { + this.state.yearly.specificMonthDay.day = dayOfMonth.replace('W', ''); this.state.yearly.runOnWeekday = true; } else { this.state.yearly.specificMonthDay.day = dayOfMonth; @@ -579,11 +572,7 @@ export class CpsSchedulerComponent implements OnInit, OnChanges { this.state.yearly.specificMonthDay.hourType = this._getHourType(parsedHours); this.state.yearly.specificMonthDay.minutes = Number(minutes); - } else if ( - cronSeven.match( - /\d+ \d+ \d+ \? \d+ (MON|TUE|WED|THU|FRI|SAT|SUN)((#[1-5])|L) \*/ - ) - ) { + } else if (this._isYearlySpecificMonthWeekPattern(cronSeven)) { const day = dayOfWeek.substring(0, 3); const monthWeek = dayOfWeek.substring(3); this.activeScheduleType = 'Yearly'; @@ -604,6 +593,49 @@ export class CpsSchedulerComponent implements OnInit, OnChanges { this._cdr.detectChanges(); } + // Enhanced pattern matching methods with support for all EventBridge features + private _isMinutesPattern(cronSeven: string): boolean { + return /^\d+ 0\/\d+ \* (1\/1|\*) \* \? \*$/.test(cronSeven); + } + + private _isHourlyPattern(cronSeven: string): boolean { + return /^\d+ \d+ 0\/\d+ (1\/1|\*) \* \? \*$/.test(cronSeven); + } + + private _isDailyEveryDaysPattern(cronSeven: string): boolean { + return /^\d+ \d+ \d+ 1\/\d+ \* \? \*$/.test(cronSeven); + } + + private _isDailyWeekdayPattern(cronSeven: string): boolean { + return /^\d+ \d+ \d+ \? \* MON-FRI \*$/.test(cronSeven); + } + + private _isWeeklyPattern(cronSeven: string): boolean { + return /^\d+ \d+ \d+ \? \* (MON|TUE|WED|THU|FRI|SAT|SUN)(,(MON|TUE|WED|THU|FRI|SAT|SUN))* \*$/.test( + cronSeven + ); + } + + private _isMonthlySpecificDayPattern(cronSeven: string): boolean { + return /^\d+ \d+ \d+ (\d+|L|LW|\d+W) 1\/\d+ \? \*$/.test(cronSeven); + } + + private _isMonthlySpecificWeekDayPattern(cronSeven: string): boolean { + return /^\d+ \d+ \d+ \? \d+\/\d+ (MON|TUE|WED|THU|FRI|SAT|SUN)((#[1-5])|L) \*$/.test( + cronSeven + ); + } + + private _isYearlySpecificMonthDayPattern(cronSeven: string): boolean { + return /^\d+ \d+ \d+ (\d+|L|LW|\d+W) \d+ \? \*$/.test(cronSeven); + } + + private _isYearlySpecificMonthWeekPattern(cronSeven: string): boolean { + return /^\d+ \d+ \d+ \? \d+ (MON|TUE|WED|THU|FRI|SAT|SUN)((#[1-5])|L) \*$/.test( + cronSeven + ); + } + private _getDefaultState() { const [defaultHours, defaultMinutes] = this.defaultTime .split(':') diff --git a/projects/cps-ui-kit/src/lib/services/cron-validation.service.spec.ts b/projects/cps-ui-kit/src/lib/services/cron-validation.service.spec.ts new file mode 100644 index 00000000..52ff034d --- /dev/null +++ b/projects/cps-ui-kit/src/lib/services/cron-validation.service.spec.ts @@ -0,0 +1,275 @@ +import { TestBed } from '@angular/core/testing'; +import { CronValidationService } from './cron-validation.service'; + +describe('CronValidationService', () => { + let service: CronValidationService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(CronValidationService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('Basic Structure Validation', () => { + it('should validate 6-field cron expressions', () => { + expect(service.isValidCron('0 12 * * ? *')).toBe(true); + expect(service.isValidCron('0 12 * *')).toBe(false); // Only 4 fields + expect(service.isValidCron('0 12 * * ? * extra')).toBe(false); // 7 fields + }); + + it('should handle empty expressions based on allowEmpty parameter', () => { + expect(service.isValidCron('', true)).toBe(true); + expect(service.isValidCron('', false)).toBe(false); + expect(service.isValidCron('')).toBe(false); // Default allowEmpty = false + }); + + it('should reject non-string inputs', () => { + expect(service.isValidCron(null as any)).toBe(false); + expect(service.isValidCron(undefined as any)).toBe(false); + expect(service.isValidCron(123 as any)).toBe(false); + }); + }); + + describe('Range Pattern Validation', () => { + describe('Numeric Ranges', () => { + it('should validate simple numeric ranges', () => { + expect(service.isValidCron('0 9-17 * * ? *')).toBe(true); // 9am to 5pm + expect(service.isValidCron('1-5 * * * ? *')).toBe(true); // Minutes 1-5 + expect(service.isValidCron('0 0 1-15 * ? *')).toBe(true); // First half of month + }); + + it('should validate ranges with steps', () => { + expect(service.isValidCron('0 9-17/2 * * ? *')).toBe(true); // Every 2nd hour 9-17 + expect(service.isValidCron('1-30/5 * * * ? *')).toBe(true); // Every 5th minute 1-30 + expect(service.isValidCron('0 0 1-31/7 * ? *')).toBe(true); // Every 7th day + }); + + it('should reject invalid numeric ranges', () => { + expect(service.isValidCron('0 25-30 * * ? *')).toBe(false); // Hour 25 invalid + expect(service.isValidCron('60-65 * * * ? *')).toBe(false); // Minute 60+ invalid + expect(service.isValidCron('0 0 32-35 * ? *')).toBe(false); // Day 32+ invalid + }); + + it('should reject backwards ranges', () => { + expect(service.isValidCron('0 17-9 * * ? *')).toBe(false); // End before start + expect(service.isValidCron('30-10 * * * ? *')).toBe(false); // Backwards minute range + }); + }); + + describe('Named Day Ranges', () => { + it('should validate named day ranges', () => { + expect(service.isValidCron('0 9 ? * MON-FRI *')).toBe(true); // Weekdays + expect(service.isValidCron('0 9 ? * SAT-SUN *')).toBe(true); // Weekend + expect(service.isValidCron('0 9 ? * TUE-THU *')).toBe(true); // Tue-Thu + }); + + it('should validate mixed case day names', () => { + expect(service.isValidCron('0 9 ? * mon-fri *')).toBe(true); + expect(service.isValidCron('0 9 ? * Mon-Fri *')).toBe(true); + }); + + it('should validate day ranges with steps', () => { + expect(service.isValidCron('0 9 ? * MON-FRI/2 *')).toBe(true); // Every other weekday + expect(service.isValidCron('0 9 ? * SUN-SAT/3 *')).toBe(true); // Every 3rd day + }); + + it('should reject invalid day names', () => { + expect(service.isValidCron('0 9 ? * INVALID-FRI *')).toBe(false); + expect(service.isValidCron('0 9 ? * MON-INVALID *')).toBe(false); + }); + }); + + describe('Named Month Ranges', () => { + it('should validate named month ranges', () => { + expect(service.isValidCron('0 9 15 JAN-MAR ? *')).toBe(true); // Q1 + expect(service.isValidCron('0 9 1 APR-JUN ? *')).toBe(true); // Q2 + expect(service.isValidCron('0 9 * JUL-SEP ? *')).toBe(true); // Q3 + expect(service.isValidCron('0 9 * OCT-DEC ? *')).toBe(true); // Q4 + }); + + it('should validate month ranges with steps', () => { + expect(service.isValidCron('0 9 1 JAN-DEC/3 ? *')).toBe(true); // Quarterly + expect(service.isValidCron('0 9 15 JAN-JUN/2 ? *')).toBe(true); // Every other month + }); + + it('should validate mixed case month names', () => { + expect(service.isValidCron('0 9 1 jan-mar ? *')).toBe(true); + expect(service.isValidCron('0 9 1 Jan-Mar ? *')).toBe(true); + }); + + it('should reject invalid month names', () => { + expect(service.isValidCron('0 9 1 INVALID-MAR ? *')).toBe(false); + expect(service.isValidCron('0 9 1 JAN-INVALID ? *')).toBe(false); + }); + }); + }); + + describe('Step Pattern Validation', () => { + it('should validate wildcard step patterns', () => { + expect(service.isValidCron('*/15 * * * ? *')).toBe(true); // Every 15 minutes + expect(service.isValidCron('0 */4 * * ? *')).toBe(true); // Every 4 hours + expect(service.isValidCron('0 0 */5 * ? *')).toBe(true); // Every 5 days + }); + + it('should validate numeric step patterns', () => { + expect(service.isValidCron('5/10 * * * ? *')).toBe(true); // Every 10min from 5 + expect(service.isValidCron('0 2/6 * * ? *')).toBe(true); // Every 6hr from 2am + expect(service.isValidCron('0 0 1/14 * ? *')).toBe(true); // Every 14 days from 1st + }); + + it('should reject invalid step values', () => { + expect(service.isValidCron('*/0 * * * ? *')).toBe(false); // Step 0 invalid + expect(service.isValidCron('*/-5 * * * ? *')).toBe(false); // Negative step + expect(service.isValidCron('*/abc * * * ? *')).toBe(false); // Non-numeric step + }); + }); + + describe('List Pattern Validation', () => { + it('should validate numeric lists', () => { + expect(service.isValidCron('0,15,30,45 * * * ? *')).toBe(true); // Specific minutes + expect(service.isValidCron('0 9,12,17 * * ? *')).toBe(true); // Specific hours + expect(service.isValidCron('0 0 1,15 * ? *')).toBe(true); // 1st and 15th + }); + + it('should validate named day lists', () => { + expect(service.isValidCron('0 9 ? * MON,WED,FRI *')).toBe(true); + expect(service.isValidCron('0 9 ? * SAT,SUN *')).toBe(true); + }); + + it('should validate named month lists', () => { + expect(service.isValidCron('0 9 1 JAN,APR,JUL,OCT ? *')).toBe(true); // Quarterly + expect(service.isValidCron('0 9 * JUN,JUL,AUG ? *')).toBe(true); // Summer + }); + + it('should validate mixed lists (ranges and values)', () => { + expect(service.isValidCron('0 9 1,15,L * ? *')).toBe(true); // 1st, 15th, last day + expect(service.isValidCron('0 9 ? * MON-WED,FRI *')).toBe(true); // Mon-Wed + Fri + }); + + it('should reject lists with invalid values', () => { + expect(service.isValidCron('0,70 * * * ? *')).toBe(false); // Minute 70 invalid + expect(service.isValidCron('0 25,26 * * ? *')).toBe(false); // Hours 25,26 invalid + }); + }); + + describe('Special Characters', () => { + describe('L (Last) Character', () => { + it('should validate L in day-of-month field', () => { + expect(service.isValidCron('0 9 L * ? *')).toBe(true); // Last day of month + expect(service.isValidCron('0 9 1,15,L * ? *')).toBe(true); // 1st, 15th, last + }); + + it('should validate L with day names in day-of-week field', () => { + expect(service.isValidCron('0 9 ? * FRIL *')).toBe(true); // Last Friday + expect(service.isValidCron('0 9 ? * SUNL *')).toBe(true); // Last Sunday + }); + + it('should reject L in invalid fields', () => { + expect(service.isValidCron('L 9 * * ? *')).toBe(false); // L in minutes + expect(service.isValidCron('0 L * * ? *')).toBe(false); // L in hours + }); + }); + + describe('W (Weekday) Character', () => { + it('should validate W with day numbers', () => { + expect(service.isValidCron('0 9 15W * ? *')).toBe(true); // Weekday nearest 15th + expect(service.isValidCron('0 9 1W * ? *')).toBe(true); // Weekday nearest 1st + }); + + it('should validate LW (last weekday)', () => { + expect(service.isValidCron('0 9 LW * ? *')).toBe(true); + }); + + it('should reject W in invalid contexts', () => { + expect(service.isValidCron('0 9W * * ? *')).toBe(false); // W without number + expect(service.isValidCron('0 9 ? * MONW *')).toBe(false); // W with day name + }); + }); + + describe('# (Hash) Character', () => { + it('should validate hash with day names', () => { + expect(service.isValidCron('0 9 ? * MON#1 *')).toBe(true); // 1st Monday + expect(service.isValidCron('0 9 ? * FRI#2 *')).toBe(true); // 2nd Friday + expect(service.isValidCron('0 9 ? * SUN#5 *')).toBe(true); // 5th Sunday + }); + + it('should reject invalid hash patterns', () => { + expect(service.isValidCron('0 9 ? * MON#0 *')).toBe(false); // Week 0 invalid + expect(service.isValidCron('0 9 ? * MON#6 *')).toBe(false); // Week 6 invalid + expect(service.isValidCron('0 9 15#1 * ? *')).toBe(false); // Hash in day-of-month + }); + }); + }); + + describe('Mutual Exclusivity Rules', () => { + it('should enforce day-of-month and day-of-week mutual exclusivity', () => { + expect(service.isValidCron('0 9 15 * ? *')).toBe(true); // Specific day, any weekday + expect(service.isValidCron('0 9 ? * MON *')).toBe(true); // Any day, specific weekday + expect(service.isValidCron('0 9 15 * MON *')).toBe(false); // Both specific - invalid + }); + + it('should allow wildcards in both day fields', () => { + expect(service.isValidCron('0 9 * * ? *')).toBe(true); + expect(service.isValidCron('0 9 ? * * *')).toBe(true); + }); + }); + + describe('Field Range Validation', () => { + it('should validate field ranges correctly', () => { + // Minutes: 0-59 + expect(service.isValidCron('0 9 * * ? *')).toBe(true); + expect(service.isValidCron('59 9 * * ? *')).toBe(true); + expect(service.isValidCron('60 9 * * ? *')).toBe(false); + + // Hours: 0-23 + expect(service.isValidCron('0 0 * * ? *')).toBe(true); + expect(service.isValidCron('0 23 * * ? *')).toBe(true); + expect(service.isValidCron('0 24 * * ? *')).toBe(false); + + // Day-of-month: 1-31 + expect(service.isValidCron('0 9 1 * ? *')).toBe(true); + expect(service.isValidCron('0 9 31 * ? *')).toBe(true); + expect(service.isValidCron('0 9 32 * ? *')).toBe(false); + + // Month: 1-12 + expect(service.isValidCron('0 9 * 1 ? *')).toBe(true); + expect(service.isValidCron('0 9 * 12 ? *')).toBe(true); + expect(service.isValidCron('0 9 * 13 ? *')).toBe(false); + + // Day-of-week: 1-7 + expect(service.isValidCron('0 9 ? * 1 *')).toBe(true); + expect(service.isValidCron('0 9 ? * 7 *')).toBe(true); + expect(service.isValidCron('0 9 ? * 8 *')).toBe(false); + + // Year: 1970-2199 + expect(service.isValidCron('0 9 * * ? 1970')).toBe(true); + expect(service.isValidCron('0 9 * * ? 2199')).toBe(true); + expect(service.isValidCron('0 9 * * ? 2200')).toBe(false); + }); + }); + + describe('Complex Real-World Patterns', () => { + it('should validate business hour patterns', () => { + expect(service.isValidCron('0 9-17 * * MON-FRI *')).toBe(true); + expect(service.isValidCron('*/15 9-17 * * MON-FRI *')).toBe(true); + }); + + it('should validate quarterly patterns', () => { + expect(service.isValidCron('0 9 1 JAN,APR,JUL,OCT ? *')).toBe(true); + expect(service.isValidCron('0 9 1 1,4,7,10 ? *')).toBe(true); + }); + + it('should validate maintenance windows', () => { + expect(service.isValidCron('0 2 * * SUN *')).toBe(true); // Sunday 2am + expect(service.isValidCron('0 2 L * ? *')).toBe(true); // Last day 2am + }); + + it('should validate reporting schedules', () => { + expect(service.isValidCron('0 8 1,15 * ? *')).toBe(true); // Bi-monthly + expect(service.isValidCron('0 8 ? * MON#1 *')).toBe(true); // First Monday + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/services/cron-validation.service.ts b/projects/cps-ui-kit/src/lib/services/cron-validation.service.ts new file mode 100644 index 00000000..86ebb748 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/services/cron-validation.service.ts @@ -0,0 +1,462 @@ +import { Injectable } from '@angular/core'; + +/** + * Service for validating AWS EventBridge Scheduler cron expressions. + * + * This service handles all cron validation logic, supporting the full EventBridge Scheduler + * cron expression format which extends standard Unix cron with additional features for + * cloud-scale scheduling. + * + * EventBridge Scheduler Format: minutes hours day-of-month month day-of-week year + * + * Key Features: + * - Wildcards: asterisk (any value), question mark (any value for day fields) + * - Ranges: 1-5, MON-FRI, JAN-MAR + * - Steps: asterisk/15, 5/10, 1-5/2 + * - Lists: 1,3,5, MON,WED,FRI + * - Special chars: L (last), W (weekday), hash (nth occurrence) + */ +@Injectable({ + providedIn: 'root' +}) +export class CronValidationService { + /** + * Validates a complete EventBridge Scheduler cron expression. + * + * @param cron - The 6-field cron expression to validate + * @param allowEmpty - Whether to allow empty cron expressions + * @returns boolean - True if the cron expression is valid + */ + isValidCron(cron: string, allowEmpty = false): boolean { + if (typeof cron !== 'string') return false; + + if (allowEmpty && cron === '') return true; + if (!cron) return false; + + const parts = cron.split(' '); + if (parts.length !== 6) { + return false; + } + + // Validate each field according to EventBridge Scheduler rules + return this.validateCronFields(parts); + } + + /** + * Validates all six fields of an EventBridge Scheduler cron expression. + * + * EventBridge Scheduler uses a 6-field cron format: minutes hours day-of-month month day-of-week year + * This method orchestrates validation of each field according to AWS EventBridge Scheduler rules, + * which extend standard Unix cron with additional features for cloud-scale scheduling. + * + * Field Ranges and Special Features: + * - Minutes (0-59): Standard numeric, ranges, steps, lists + * - Hours (0-23): Standard numeric, ranges, steps, lists + * - Day-of-month (1-31): Numeric + special chars (L, W, LW) + * - Month (1-12): Numeric or named (JAN-DEC), ranges, steps, lists + * - Day-of-week (1-7): Numeric or named (SUN-SAT), special chars (L, hash) + * - Year (1970-2199): Extended range for long-term scheduling + * + * Key EventBridge Rules: + * - Day-of-month and day-of-week are mutually exclusive (one must be asterisk or question mark) + * - Question mark (?) is only valid for day-of-month and day-of-week fields + * - Special characters (L, W, hash) provide advanced scheduling capabilities + * + * @param parts - Array of 6 cron field strings [minutes, hours, dayOfMonth, month, dayOfWeek, year] + * @returns boolean - True if all fields are valid and follow EventBridge Scheduler rules + */ + private validateCronFields(parts: string[]): boolean { + const [minutes, hours, dayOfMonth, month, dayOfWeek, year] = parts; + + // Validate minutes (0-59) - use enhanced validation + if (!this.validateComplexField(minutes, 0, 59, 'minutes')) return false; + + // Validate hours (0-23) - use enhanced validation + if (!this.validateComplexField(hours, 0, 23, 'hours')) return false; + + // Validate day of month (1-31 or wildcards) + if (!this.validateDayOfMonth(dayOfMonth)) return false; + + // Validate month (1-12 or JAN-DEC) - use enhanced validation + if (!this.validateMonth(month)) return false; + + // Validate day of week (1-7 or SUN-SAT) - enhanced method will handle this + if (!this.validateDayOfWeek(dayOfWeek)) return false; + + // Validate year (1970-2199) - use enhanced validation + if (!this.validateComplexField(year, 1970, 2199, 'year')) return false; + + // Validate mutual exclusivity of day-of-month and day-of-week + if (!this.validateDayMutualExclusivity(dayOfMonth, dayOfWeek)) return false; + + return true; + } + + /** + * Enhanced validation for complex EventBridge Scheduler cron field patterns. + * Supports all EventBridge features including ranges, steps, lists, and special characters. + * + * This method follows the AWS EventBridge Scheduler cron expression format which is based on + * the Unix cron format but includes additional features for more flexible scheduling. + * + * Supported patterns: + * - Wildcards: asterisk (any value), question mark (any value for day fields) + * - Ranges: 1-5 (values 1 through 5), MON-FRI (Monday through Friday) + * - Steps: asterisk/10 (every 10th value), 1-5/2 (every 2nd value from 1 to 5) + * - Lists: 1,3,5 (specific values), MON,WED,FRI (specific days) + * - Special chars: L (last), W (weekday), hash (nth occurrence) + * + * @param field - The cron field value to validate (examples: "1-5/2", "MON-FRI", "asterisk/10") + * @param min - Minimum valid numeric value for this field type + * @param max - Maximum valid numeric value for this field type + * @param type - The cron field type ('minutes', 'hours', 'dayOfMonth', 'month', 'dayOfWeek', 'year') + * @returns boolean - True if the field is valid according to EventBridge Scheduler rules + */ + private validateComplexField( + field: string, + min: number, + max: number, + type: string + ): boolean { + // Handle wildcard characters - following EventBridge Scheduler mutual exclusivity rules + // '*' means "any value" and is valid for all fields + // '?' means "no specific value" and is only valid for day-of-month and day-of-week fields + if (field === '*' || field === '?') { + return type === 'dayOfMonth' || type === 'dayOfWeek' || field === '*'; + } + + // Handle comma-separated lists: "1,3,5" or "MON,WED,FRI" or "MON-WED,FRI" + // Allows EventBridge to trigger on multiple specific values within a field + if (field.includes(',')) { + return field.split(',').every((val) => { + const trimmedVal = val.trim(); + // Recursively validate each part of the list + return this.validateComplexField(trimmedVal, min, max, type); + }); + } + + // Handle complex range with step patterns: "1-5/2" (every 2nd value from 1 to 5) + // This allows EventBridge to schedule at intervals within a specific range + if (field.includes('-') && field.includes('/')) { + const [range, step] = field.split('/'); + const [start, end] = range.split('-'); + return this.validateRangeWithStep(start, end, step, min, max, type); + } + + // Handle simple range patterns: "1-5" (values from 1 to 5), "MON-FRI" (Monday to Friday) + // EventBridge supports both numeric and named ranges for time-based scheduling + if (field.includes('-')) { + const [start, end] = field.split('-'); + return this.validateSimpleRange(start, end, min, max, type); + } + + // Handle step patterns from start: "5/10" (every 10th value starting from 5) + // EventBridge uses this for interval-based scheduling from a specific starting point + if (field.includes('/')) { + const [start, step] = field.split('/'); + return this.validateStepField(start, step, min, max, type); + } + + // Handle single values and EventBridge special characters (L, W, hash) + // These provide advanced scheduling capabilities like "last day of month" or "3rd Tuesday" + return this.validateSingleValue(field, min, max, type); + } + + /** + * Validates single values and EventBridge Scheduler special characters. + * + * This method handles the validation of individual field values including: + * - Numeric values within specified ranges + * - Special EventBridge characters for advanced scheduling + * - Named values like month names (JAN, FEB) and day names (SUN, MON) + * + * EventBridge Special Characters: + * - L: Last day of month (day-of-month) or last occurrence of weekday (day-of-week) + * - W: Nearest weekday to the specified day (day-of-month only) + * - LW: Last weekday of the month (day-of-month only) + * - hash: Nth occurrence of weekday (e.g., "3#2" = 3rd Tuesday of month) + * + * @param value - The single value to validate (e.g., "15", "L", "15W", "MON", "3#2") + * @param min - Minimum valid numeric value for this field type + * @param max - Maximum valid numeric value for this field type + * @param type - The cron field type for context-specific validation + * @returns boolean - True if the value is valid for EventBridge Scheduler + */ + private validateSingleValue( + value: string, + min: number, + max: number, + type: string + ): boolean { + // Handle special characters for day of month field + // EventBridge supports advanced day-of-month scheduling patterns + if (type === 'dayOfMonth') { + // 'L' = last day of month, 'LW' = last weekday of month + if (value === 'L' || value === 'LW') return true; + // '15W' = nearest weekday to the 15th (if 15th is weekend, use closest weekday) + if (value.endsWith('W')) { + const day = Number(value.slice(0, -1)); + return day >= 1 && day <= 31; + } + } + + // Handle special characters for day of week field + // EventBridge supports advanced day-of-week scheduling patterns + if (type === 'dayOfWeek') { + // 'MONL' = last Monday of month (last occurrence) + if (value.endsWith('L')) { + const day = value.slice(0, -1); + return this.isValidDayOfWeek(day); + } + // '3#2' = 3rd Tuesday of month (3=Tuesday, 2=second occurrence) + if (value.includes('#')) { + const [day, week] = value.split('#'); + return ( + this.isValidDayOfWeek(day) && Number(week) >= 1 && Number(week) <= 5 + ); + } + // Standard day names: SUN, MON, TUE, etc. + if (this.isValidDayOfWeek(value)) return true; + } + + // Handle month names (JAN, FEB, MAR, etc.) for month field + // EventBridge allows both numeric (1-12) and named month values + if (type === 'month' && this.isValidMonthName(value)) return true; + + // Validate numeric values within the specified range + // This covers standard numeric scheduling (minutes: 0-59, hours: 0-23, etc.) + const num = Number(value); + return !isNaN(num) && num >= min && num <= max; + } + + /** + * Validates range patterns with step intervals for EventBridge Scheduler. + * + * This method handles complex range-step patterns like "1-5/2" which means + * "every 2nd value from 1 to 5" (resulting in: 1, 3, 5). + * + * EventBridge uses this pattern for flexible interval scheduling within specific ranges. + * For example: "9-17/2" for hours would trigger at 9:00, 11:00, 13:00, 15:00, 17:00. + * + * @param start - Range start value (numeric or named like "MON") + * @param end - Range end value (numeric or named like "FRI") + * @param step - Step interval as string (must be positive integer) + * @param min - Minimum allowed value for this field type + * @param max - Maximum allowed value for this field type + * @param type - Field type for context-aware validation (dayOfWeek gets special handling) + * @returns boolean - True if the range-step pattern is valid for EventBridge + */ + private validateRangeWithStep( + start: string, + end: string, + step: string, + min: number, + max: number, + type: string + ): boolean { + // Validate step value - must be positive integer for EventBridge compliance + const stepNum = Number(step); + if (isNaN(stepNum) || stepNum <= 0) return false; + + // Special handling for day-of-week ranges (supports named days like MON-FRI) + // EventBridge allows both numeric (1-7) and named (SUN-SAT) day ranges + if (type === 'dayOfWeek') { + return this.isValidDayOfWeek(start) && this.isValidDayOfWeek(end); + } + + // Special handling for month ranges (supports named months like JAN-DEC) + // EventBridge allows both numeric (1-12) and named (JAN-DEC) month ranges + if (type === 'month') { + const startValid = + this.isValidMonthName(start) || + this.validateSingleValue(start, min, max, type); + const endValid = + this.isValidMonthName(end) || + this.validateSingleValue(end, min, max, type); + return startValid && endValid; + } + + // Validate numeric ranges for other field types + // Ensures range boundaries are valid and logical (start <= end) + const startNum = Number(start); + const endNum = Number(end); + return ( + !isNaN(startNum) && + !isNaN(endNum) && + startNum >= min && + endNum <= max && + startNum <= endNum + ); + } + + /** + * Validates simple range patterns without step intervals for EventBridge Scheduler. + * + * This method handles basic range patterns like "1-5" (values 1 through 5) or + * "MON-FRI" (Monday through Friday). EventBridge supports both numeric and + * named ranges for flexible scheduling. + * + * Examples: + * - "9-17" for hours: triggers every hour from 9:00 to 17:00 (9am to 5pm) + * - "MON-FRI" for day-of-week: triggers Monday through Friday + * - "JAN-MAR" for months: triggers January through March + * + * @param start - Range start value (numeric or named) + * @param end - Range end value (numeric or named) + * @param min - Minimum allowed value for this field type + * @param max - Maximum allowed value for this field type + * @param type - Field type for validation context (affects named value handling) + * @returns boolean - True if the range pattern is valid for EventBridge + */ + private validateSimpleRange( + start: string, + end: string, + min: number, + max: number, + type: string + ): boolean { + // Handle day-of-week ranges with named values (MON-FRI, SUN-SAT, etc.) + // EventBridge supports both numeric (1-7) and named day ranges + if (type === 'dayOfWeek') { + return this.isValidDayOfWeek(start) && this.isValidDayOfWeek(end); + } + + // Handle month ranges with named values (JAN-DEC, etc.) + // EventBridge allows both numeric (1-12) and named month ranges + if (type === 'month') { + const startValid = + this.isValidMonthName(start) || + this.validateSingleValue(start, min, max, type); + const endValid = + this.isValidMonthName(end) || + this.validateSingleValue(end, min, max, type); + return startValid && endValid; + } + + // Validate numeric ranges for other field types + // Ensures both range boundaries are numeric, within limits, and logical (start <= end) + const startNum = Number(start); + const endNum = Number(end); + return ( + !isNaN(startNum) && + !isNaN(endNum) && + startNum >= min && + endNum <= max && + startNum <= endNum + ); + } + + /** + * Validates step patterns from a starting point for EventBridge Scheduler. + * + * This method handles step patterns like "5/10" (every 10th value starting from 5) + * or asterisk/15 (every 15th value starting from minimum). EventBridge uses this for + * interval-based scheduling from specific starting points. + * + * Examples: + * - "0/15" for minutes: triggers at 0, 15, 30, 45 minutes past the hour + * - asterisk/5 for minutes: triggers every 5 minutes (0, 5, 10, 15, ...) + * - "2/3" for day-of-month: triggers every 3rd day starting from the 2nd (2, 5, 8, ...) + * + * @param start - Starting value (can be asterisk for wildcard start or specific value) + * @param step - Step interval as string (must be positive integer) + * @param min - Minimum allowed value for this field type + * @param max - Maximum allowed value for this field type + * @param type - Field type for validation context + * @returns boolean - True if the step pattern is valid for EventBridge + */ + private validateStepField( + start: string, + step: string, + min: number, + max: number, + type: string + ): boolean { + // Validate step value - must be positive integer for EventBridge compliance + const stepNum = Number(step); + if (isNaN(stepNum) || stepNum <= 0) return false; + + // Handle wildcard start: asterisk/step means start from minimum value with given step + // This is the most common pattern for regular intervals (e.g., asterisk/15 = every 15 minutes) + if (start === '*') return true; + + // Handle named day values for day-of-week step patterns + // EventBridge supports patterns like "MON/2" (every 2nd occurrence starting Monday) + if (type === 'dayOfWeek') { + return this.isValidDayOfWeek(start); + } + + // Validate numeric start values - must be within field's valid range + // EventBridge requires the starting point to be a valid value for the field type + const startNum = Number(start); + return !isNaN(startNum) && startNum >= min && startNum <= max; + } + + /** + * Validates day-of-month field with special EventBridge characters. + */ + private validateDayOfMonth(dayOfMonth: string): boolean { + return this.validateComplexField(dayOfMonth, 1, 31, 'dayOfMonth'); + } + + /** + * Validates month field with support for named months. + */ + private validateMonth(month: string): boolean { + return this.validateComplexField(month, 1, 12, 'month'); + } + + /** + * Validates day-of-week field with support for named days and special characters. + */ + private validateDayOfWeek(dayOfWeek: string): boolean { + return this.validateComplexField(dayOfWeek, 1, 7, 'dayOfWeek'); + } + + /** + * Validates mutual exclusivity rule for day-of-month and day-of-week fields. + * EventBridge requires that one of these fields must be a wildcard. + */ + private validateDayMutualExclusivity( + dayOfMonth: string, + dayOfWeek: string + ): boolean { + // Both cannot be specific values - one must be * or ? + const dayOfMonthIsWildcard = dayOfMonth === '*' || dayOfMonth === '?'; + const dayOfWeekIsWildcard = dayOfWeek === '*' || dayOfWeek === '?'; + + return dayOfMonthIsWildcard || dayOfWeekIsWildcard; + } + + /** + * Checks if a value represents a valid day of the week (numeric or named). + */ + private isValidDayOfWeek(day: string): boolean { + const validDays = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']; + if (validDays.includes(day.toUpperCase())) return true; + + const num = Number(day); + return !isNaN(num) && num >= 1 && num <= 7; + } + + /** + * Checks if a value represents a valid month name. + */ + private isValidMonthName(month: string): boolean { + const validMonths = [ + 'JAN', + 'FEB', + 'MAR', + 'APR', + 'MAY', + 'JUN', + 'JUL', + 'AUG', + 'SEP', + 'OCT', + 'NOV', + 'DEC' + ]; + return validMonths.includes(month.toUpperCase()); + } +} From 4518d3220977ade49b69abdc945a0ea919873706 Mon Sep 17 00:00:00 2001 From: TerranceKhumalo-absa Date: Tue, 23 Sep 2025 17:52:58 +0200 Subject: [PATCH 3/5] refactor: update CronValidationService to generalize validation for extended cron expressions --- .../lib/services/cron-validation.service.ts | 105 +++++++++--------- 1 file changed, 52 insertions(+), 53 deletions(-) diff --git a/projects/cps-ui-kit/src/lib/services/cron-validation.service.ts b/projects/cps-ui-kit/src/lib/services/cron-validation.service.ts index 86ebb748..64ea7906 100644 --- a/projects/cps-ui-kit/src/lib/services/cron-validation.service.ts +++ b/projects/cps-ui-kit/src/lib/services/cron-validation.service.ts @@ -1,13 +1,13 @@ import { Injectable } from '@angular/core'; /** - * Service for validating AWS EventBridge Scheduler cron expressions. + * Service for validating 6-field cron expressions with extended features. * - * This service handles all cron validation logic, supporting the full EventBridge Scheduler - * cron expression format which extends standard Unix cron with additional features for - * cloud-scale scheduling. + * This service handles cron validation logic for extended cron expression formats + * that support additional features beyond standard Unix cron for more flexible + * scheduling capabilities. * - * EventBridge Scheduler Format: minutes hours day-of-month month day-of-week year + * Format: minutes hours day-of-month month day-of-week year * * Key Features: * - Wildcards: asterisk (any value), question mark (any value for day fields) @@ -21,7 +21,7 @@ import { Injectable } from '@angular/core'; }) export class CronValidationService { /** - * Validates a complete EventBridge Scheduler cron expression. + * Validates a complete 6-field cron expression. * * @param cron - The 6-field cron expression to validate * @param allowEmpty - Whether to allow empty cron expressions @@ -38,32 +38,31 @@ export class CronValidationService { return false; } - // Validate each field according to EventBridge Scheduler rules return this.validateCronFields(parts); } /** - * Validates all six fields of an EventBridge Scheduler cron expression. + * Validates all six fields of an extended cron expression. * - * EventBridge Scheduler uses a 6-field cron format: minutes hours day-of-month month day-of-week year - * This method orchestrates validation of each field according to AWS EventBridge Scheduler rules, - * which extend standard Unix cron with additional features for cloud-scale scheduling. + * Extended cron format uses 6 fields: minutes hours day-of-month month day-of-week year + * This method validates each field according to extended cron syntax rules, + * which build upon standard Unix cron with additional features. * - * Field Ranges and Special Features: - * - Minutes (0-59): Standard numeric, ranges, steps, lists - * - Hours (0-23): Standard numeric, ranges, steps, lists + * Field Ranges and Features: + * - Minutes (0-59): Numeric, ranges, steps, lists + * - Hours (0-23): Numeric, ranges, steps, lists * - Day-of-month (1-31): Numeric + special chars (L, W, LW) * - Month (1-12): Numeric or named (JAN-DEC), ranges, steps, lists - * - Day-of-week (1-7): Numeric or named (SUN-SAT), special chars (L, hash) + * - Day-of-week (1-7): Numeric or named (SUN-SAT), special chars (L, #) * - Year (1970-2199): Extended range for long-term scheduling * - * Key EventBridge Rules: - * - Day-of-month and day-of-week are mutually exclusive (one must be asterisk or question mark) - * - Question mark (?) is only valid for day-of-month and day-of-week fields - * - Special characters (L, W, hash) provide advanced scheduling capabilities + * Key Rules: + * - Day-of-month and day-of-week are mutually exclusive (one must be * or ?) + * - ? is only valid for day-of-month and day-of-week fields + * - Special characters provide advanced scheduling capabilities * * @param parts - Array of 6 cron field strings [minutes, hours, dayOfMonth, month, dayOfWeek, year] - * @returns boolean - True if all fields are valid and follow EventBridge Scheduler rules + * @returns boolean - True if all fields are valid and follow extended cron rules */ private validateCronFields(parts: string[]): boolean { const [minutes, hours, dayOfMonth, month, dayOfWeek, year] = parts; @@ -93,11 +92,11 @@ export class CronValidationService { } /** - * Enhanced validation for complex EventBridge Scheduler cron field patterns. - * Supports all EventBridge features including ranges, steps, lists, and special characters. + * Enhanced validation for complex cron field patterns. + * Supports extended cron features including ranges, steps, lists, and special characters. * - * This method follows the AWS EventBridge Scheduler cron expression format which is based on - * the Unix cron format but includes additional features for more flexible scheduling. + * This method handles extended cron expression syntax which builds upon + * standard Unix cron format with additional features for flexible scheduling. * * Supported patterns: * - Wildcards: asterisk (any value), question mark (any value for day fields) @@ -118,7 +117,7 @@ export class CronValidationService { max: number, type: string ): boolean { - // Handle wildcard characters - following EventBridge Scheduler mutual exclusivity rules + // Handle wildcard characters // '*' means "any value" and is valid for all fields // '?' means "no specific value" and is only valid for day-of-month and day-of-week fields if (field === '*' || field === '?') { @@ -126,7 +125,7 @@ export class CronValidationService { } // Handle comma-separated lists: "1,3,5" or "MON,WED,FRI" or "MON-WED,FRI" - // Allows EventBridge to trigger on multiple specific values within a field + // Allows scheduling on multiple specific values within a field if (field.includes(',')) { return field.split(',').every((val) => { const trimmedVal = val.trim(); @@ -136,7 +135,7 @@ export class CronValidationService { } // Handle complex range with step patterns: "1-5/2" (every 2nd value from 1 to 5) - // This allows EventBridge to schedule at intervals within a specific range + // This allows scheduling at intervals within a specific range if (field.includes('-') && field.includes('/')) { const [range, step] = field.split('/'); const [start, end] = range.split('-'); @@ -144,37 +143,37 @@ export class CronValidationService { } // Handle simple range patterns: "1-5" (values from 1 to 5), "MON-FRI" (Monday to Friday) - // EventBridge supports both numeric and named ranges for time-based scheduling + // Extended cron supports both numeric and named ranges for time-based scheduling if (field.includes('-')) { const [start, end] = field.split('-'); return this.validateSimpleRange(start, end, min, max, type); } // Handle step patterns from start: "5/10" (every 10th value starting from 5) - // EventBridge uses this for interval-based scheduling from a specific starting point + // Extended cron uses this for interval-based scheduling from a specific starting point if (field.includes('/')) { const [start, step] = field.split('/'); return this.validateStepField(start, step, min, max, type); } - // Handle single values and EventBridge special characters (L, W, hash) + // Handle single values and special characters (L, W, #) // These provide advanced scheduling capabilities like "last day of month" or "3rd Tuesday" return this.validateSingleValue(field, min, max, type); } /** - * Validates single values and EventBridge Scheduler special characters. + * Validates single values and extended cron special characters. * * This method handles the validation of individual field values including: * - Numeric values within specified ranges - * - Special EventBridge characters for advanced scheduling + * - Special characters for advanced scheduling * - Named values like month names (JAN, FEB) and day names (SUN, MON) * - * EventBridge Special Characters: + * Extended Cron Special Characters: * - L: Last day of month (day-of-month) or last occurrence of weekday (day-of-week) * - W: Nearest weekday to the specified day (day-of-month only) * - LW: Last weekday of the month (day-of-month only) - * - hash: Nth occurrence of weekday (e.g., "3#2" = 3rd Tuesday of month) + * - #: Nth occurrence of weekday (e.g., "3#2" = 3rd Tuesday of month) * * @param value - The single value to validate (e.g., "15", "L", "15W", "MON", "3#2") * @param min - Minimum valid numeric value for this field type @@ -189,7 +188,7 @@ export class CronValidationService { type: string ): boolean { // Handle special characters for day of month field - // EventBridge supports advanced day-of-month scheduling patterns + // Extended cron supports advanced day-of-month scheduling patterns if (type === 'dayOfMonth') { // 'L' = last day of month, 'LW' = last weekday of month if (value === 'L' || value === 'LW') return true; @@ -201,7 +200,7 @@ export class CronValidationService { } // Handle special characters for day of week field - // EventBridge supports advanced day-of-week scheduling patterns + // Extended cron supports advanced day-of-week scheduling patterns if (type === 'dayOfWeek') { // 'MONL' = last Monday of month (last occurrence) if (value.endsWith('L')) { @@ -220,7 +219,7 @@ export class CronValidationService { } // Handle month names (JAN, FEB, MAR, etc.) for month field - // EventBridge allows both numeric (1-12) and named month values + // Extended cron allows both numeric (1-12) and named month values if (type === 'month' && this.isValidMonthName(value)) return true; // Validate numeric values within the specified range @@ -230,12 +229,12 @@ export class CronValidationService { } /** - * Validates range patterns with step intervals for EventBridge Scheduler. + * Validates range patterns with step intervals. * * This method handles complex range-step patterns like "1-5/2" which means * "every 2nd value from 1 to 5" (resulting in: 1, 3, 5). * - * EventBridge uses this pattern for flexible interval scheduling within specific ranges. + * Extended cron uses this pattern for flexible interval scheduling within specific ranges. * For example: "9-17/2" for hours would trigger at 9:00, 11:00, 13:00, 15:00, 17:00. * * @param start - Range start value (numeric or named like "MON") @@ -254,18 +253,18 @@ export class CronValidationService { max: number, type: string ): boolean { - // Validate step value - must be positive integer for EventBridge compliance + // Validate step value - must be positive integer const stepNum = Number(step); if (isNaN(stepNum) || stepNum <= 0) return false; // Special handling for day-of-week ranges (supports named days like MON-FRI) - // EventBridge allows both numeric (1-7) and named (SUN-SAT) day ranges + // Extended cron allows both numeric (1-7) and named (SUN-SAT) day ranges if (type === 'dayOfWeek') { return this.isValidDayOfWeek(start) && this.isValidDayOfWeek(end); } // Special handling for month ranges (supports named months like JAN-DEC) - // EventBridge allows both numeric (1-12) and named (JAN-DEC) month ranges + // Extended cron allows both numeric (1-12) and named (JAN-DEC) month ranges if (type === 'month') { const startValid = this.isValidMonthName(start) || @@ -290,10 +289,10 @@ export class CronValidationService { } /** - * Validates simple range patterns without step intervals for EventBridge Scheduler. + * Validates simple range patterns without step intervals. * * This method handles basic range patterns like "1-5" (values 1 through 5) or - * "MON-FRI" (Monday through Friday). EventBridge supports both numeric and + * "MON-FRI" (Monday through Friday). Extended cron supports both numeric and * named ranges for flexible scheduling. * * Examples: @@ -316,13 +315,13 @@ export class CronValidationService { type: string ): boolean { // Handle day-of-week ranges with named values (MON-FRI, SUN-SAT, etc.) - // EventBridge supports both numeric (1-7) and named day ranges + // Extended cron supports both numeric (1-7) and named day ranges if (type === 'dayOfWeek') { return this.isValidDayOfWeek(start) && this.isValidDayOfWeek(end); } // Handle month ranges with named values (JAN-DEC, etc.) - // EventBridge allows both numeric (1-12) and named month ranges + // Extended cron allows both numeric (1-12) and named month ranges if (type === 'month') { const startValid = this.isValidMonthName(start) || @@ -347,10 +346,10 @@ export class CronValidationService { } /** - * Validates step patterns from a starting point for EventBridge Scheduler. + * Validates step patterns from a starting point. * * This method handles step patterns like "5/10" (every 10th value starting from 5) - * or asterisk/15 (every 15th value starting from minimum). EventBridge uses this for + * or asterisk/15 (every 15th value starting from minimum). Extended cron uses this for * interval-based scheduling from specific starting points. * * Examples: @@ -372,7 +371,7 @@ export class CronValidationService { max: number, type: string ): boolean { - // Validate step value - must be positive integer for EventBridge compliance + // Validate step value - must be positive integer const stepNum = Number(step); if (isNaN(stepNum) || stepNum <= 0) return false; @@ -381,19 +380,19 @@ export class CronValidationService { if (start === '*') return true; // Handle named day values for day-of-week step patterns - // EventBridge supports patterns like "MON/2" (every 2nd occurrence starting Monday) + // Extended cron supports patterns like "MON/2" (every 2nd occurrence starting Monday) if (type === 'dayOfWeek') { return this.isValidDayOfWeek(start); } // Validate numeric start values - must be within field's valid range - // EventBridge requires the starting point to be a valid value for the field type + // Extended cron requires the starting point to be a valid value for the field type const startNum = Number(start); return !isNaN(startNum) && startNum >= min && startNum <= max; } /** - * Validates day-of-month field with special EventBridge characters. + * Validates day-of-month field with special characters. */ private validateDayOfMonth(dayOfMonth: string): boolean { return this.validateComplexField(dayOfMonth, 1, 31, 'dayOfMonth'); @@ -415,7 +414,7 @@ export class CronValidationService { /** * Validates mutual exclusivity rule for day-of-month and day-of-week fields. - * EventBridge requires that one of these fields must be a wildcard. + * Extended cron requires that one of these fields must be a wildcard. */ private validateDayMutualExclusivity( dayOfMonth: string, From 9b93ebdf59f13d90f584fa08f9204d8ddd3da8af Mon Sep 17 00:00:00 2001 From: TerranceKhumalo-absa Date: Tue, 23 Sep 2025 18:39:20 +0200 Subject: [PATCH 4/5] refactor: inject dependencies in CpsSchedulerComponent and remove constructor --- cypress/e2e/cps-scheduler.cy.ts | 40 ++++++------------- .../cps-scheduler/cps-scheduler.component.ts | 11 +++-- 2 files changed, 18 insertions(+), 33 deletions(-) diff --git a/cypress/e2e/cps-scheduler.cy.ts b/cypress/e2e/cps-scheduler.cy.ts index f5ab210b..927bfc66 100644 --- a/cypress/e2e/cps-scheduler.cy.ts +++ b/cypress/e2e/cps-scheduler.cy.ts @@ -63,8 +63,6 @@ describe('CPS Scheduler Component', () => { cy.get('[data-cy="weekly-MON"]').click(); cy.get('[data-cy="weekly-WED"]').click(); - cy.wait(1000); - // Verify timezone selector appears cy.get('[data-cy="timezone-selector"]').should('be.visible'); @@ -86,8 +84,6 @@ describe('CPS Scheduler Component', () => { cy.get('input[type="checkbox"]').check({ force: true }); }); - cy.wait(1000); - // Verify timezone selector appears cy.get('[data-cy="timezone-selector"]').should('be.visible'); @@ -143,8 +139,10 @@ describe('CPS Scheduler Component', () => { // Set custom time to 14:45 (2:45 PM) cy.get('[data-cy="monthly-weekday-timepicker"]').within(() => { - cy.get('input').eq(1).clear().type('45'); - cy.get('input').first().clear().type('14'); + cy.get('input').eq(1).clear(); + cy.get('input').eq(1).type('45'); + cy.get('input').first().clear(); + cy.get('input').first().type('14'); }); // Switch to Advanced to see generated cron @@ -164,13 +162,12 @@ describe('CPS Scheduler Component', () => { it('should accept valid cron expressions', () => { const testCron = '0 30 14 ? * MON-FRI'; + cy.get('[data-cy="advanced-cron-input"]').find('input, textarea').clear(); + cy.get('[data-cy="advanced-cron-input"]') .find('input, textarea') - .clear() .type(testCron); - cy.wait(1000); - // Verify input contains the cron cy.get('[data-cy="advanced-cron-input"]') .find('input, textarea') @@ -178,21 +175,17 @@ describe('CPS Scheduler Component', () => { }); it('should handle invalid cron expressions', () => { + cy.get('[data-cy="advanced-cron-input"]').find('input, textarea').clear(); + cy.get('[data-cy="advanced-cron-input"]') .find('input, textarea') - .clear() .type('invalid cron'); - cy.wait(1000); - // Component should not crash cy.get('[data-cy="advanced-cron-input"]').should('be.visible'); // Check validation state - cy.get('body').then(($body) => { - const hasError = $body.find('.ng-invalid, .error').length > 0; - expect(hasError).to.be.true; - }); + cy.get('.ng-invalid, .error').should('exist'); }); }); @@ -202,8 +195,6 @@ describe('CPS Scheduler Component', () => { cy.get('[data-cy="schedule-type-toggle"]').contains('Weekly').click(); cy.get('[data-cy="weekly-MON"]').click(); - cy.wait(1000); - // Switch to Advanced to verify cron was generated cy.get('[data-cy="schedule-type-toggle"]').contains('Advanced').click(); cy.get('[data-cy="advanced-cron-input"]') @@ -220,8 +211,6 @@ describe('CPS Scheduler Component', () => { cy.get('[data-cy="schedule-type-toggle"]').contains('Weekly').click(); cy.get('[data-cy="weekly-MON"]').click(); - cy.wait(1000); - // Reset to Not set cy.get('[data-cy="schedule-type-toggle"]').contains('Not set').click(); @@ -238,13 +227,12 @@ describe('CPS Scheduler Component', () => { it('should allow timezone filtering and show autocomplete options', () => { cy.get('[data-cy="schedule-type-toggle"]').contains('Advanced').click(); // Type in the autocomplete to filter timezone options + cy.get('[data-cy="timezone-select"]').find('input').clear(); + cy.get('[data-cy="timezone-select"]') .find('input') - .clear() .type('UTC', { force: true }); - cy.wait(500); - // Verify that autocomplete dropdown appears with options cy.get('.cps-autocomplete-options, .cps-autocomplete-option').should( 'exist' @@ -260,13 +248,12 @@ describe('CPS Scheduler Component', () => { cy.get('[data-cy="schedule-type-toggle"]').contains('Advanced').click(); // Type a specific timezone + cy.get('[data-cy="timezone-select"]').find('input').clear(); + cy.get('[data-cy="timezone-select"]') .find('input') - .clear() .type('Europe/London', { force: true }); - cy.wait(500); - // Verify the text remains in the input cy.get('[data-cy="timezone-select"]') .find('input') @@ -283,7 +270,6 @@ describe('CPS Scheduler Component', () => { types.forEach((type) => { cy.get('[data-cy="schedule-type-toggle"]').contains(type).click(); - cy.wait(200); }); // Should end in valid state diff --git a/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.ts b/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.ts index 7b33f306..75092016 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.ts @@ -4,6 +4,7 @@ import { ChangeDetectorRef, Component, EventEmitter, + inject, Input, OnChanges, OnInit, @@ -184,6 +185,10 @@ export class CpsSchedulerComponent implements OnInit, OnChanges { { label: 'Advanced', value: 'Advanced' } ]; + private readonly _fb = inject(FormBuilder); + private readonly _cdr = inject(ChangeDetectorRef); + private readonly _cronValidationService = inject(CronValidationService); + activeScheduleType = 'Not set'; selectOptions = this._getSelectOptions(); timeZoneOptions = timeZones.map((tz) => ({ label: tz, value: tz })); @@ -195,12 +200,6 @@ export class CpsSchedulerComponent implements OnInit, OnChanges { private _isDirty = false; private _minutesDefault = '0/1 * 1/1 * ? *'; - constructor( - private _fb: FormBuilder, - private _cdr: ChangeDetectorRef, - private _cronValidationService: CronValidationService - ) {} - ngOnInit(): void { this.state = this._getDefaultState(); From 8e27c044be9399b33ef07253f49bec4db5457abe Mon Sep 17 00:00:00 2001 From: TerranceKhumalo-absa Date: Mon, 29 Sep 2025 10:52:10 +0200 Subject: [PATCH 5/5] feat: enhance CronValidationService to restrict multiple hash expressions in day-of-week field --- .../lib/services/cron-validation.service.spec.ts | 1 + .../src/lib/services/cron-validation.service.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/projects/cps-ui-kit/src/lib/services/cron-validation.service.spec.ts b/projects/cps-ui-kit/src/lib/services/cron-validation.service.spec.ts index 52ff034d..bde1c3f2 100644 --- a/projects/cps-ui-kit/src/lib/services/cron-validation.service.spec.ts +++ b/projects/cps-ui-kit/src/lib/services/cron-validation.service.spec.ts @@ -200,6 +200,7 @@ describe('CronValidationService', () => { expect(service.isValidCron('0 9 ? * MON#0 *')).toBe(false); // Week 0 invalid expect(service.isValidCron('0 9 ? * MON#6 *')).toBe(false); // Week 6 invalid expect(service.isValidCron('0 9 15#1 * ? *')).toBe(false); // Hash in day-of-month + expect(service.isValidCron('0 9 ? * SUN#5,MON#4 *')).toBe(false); // two expressions with a hash for day-of-week field should not be allowed }); }); }); diff --git a/projects/cps-ui-kit/src/lib/services/cron-validation.service.ts b/projects/cps-ui-kit/src/lib/services/cron-validation.service.ts index 64ea7906..f22f1fe6 100644 --- a/projects/cps-ui-kit/src/lib/services/cron-validation.service.ts +++ b/projects/cps-ui-kit/src/lib/services/cron-validation.service.ts @@ -15,6 +15,7 @@ import { Injectable } from '@angular/core'; * - Steps: asterisk/15, 5/10, 1-5/2 * - Lists: 1,3,5, MON,WED,FRI * - Special chars: L (last), W (weekday), hash (nth occurrence) + * @see {@link https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html#cron-based | AWS EventBridge Scheduler - Cron-based schedules} */ @Injectable({ providedIn: 'root' @@ -409,6 +410,19 @@ export class CronValidationService { * Validates day-of-week field with support for named days and special characters. */ private validateDayOfWeek(dayOfWeek: string): boolean { + // Check for multiple hash expressions in day-of-week field + if (dayOfWeek.includes(',') && dayOfWeek.includes('#')) { + const parts = dayOfWeek.split(','); + const hashCount = parts.filter((part) => + part.trim().includes('#') + ).length; + + // AWS EventBridge: Only one hash expression allowed per day-of-week field + if (hashCount > 1) { + return false; + } + } + return this.validateComplexField(dayOfWeek, 1, 7, 'dayOfWeek'); }