diff --git a/src/modules/common/enum/testflow.enum.ts b/src/modules/common/enum/testflow.enum.ts index 8e6b02c13..44420c330 100644 --- a/src/modules/common/enum/testflow.enum.ts +++ b/src/modules/common/enum/testflow.enum.ts @@ -32,12 +32,12 @@ export enum RequestDataTypeEnum { export interface EmailData { userName: string; scheduleName: string; - scheduleLastestRun: Date; + scheduleLastestRun: string; scheduleRunResult: string; scheduleRunPassedCount: number; scheduleRunFailedCount: number; scheduleRunTotalRequest: number; - scheduleRunPassPercentage: number; + scheduleRunPassPercentage: string; scheduleTotalTime: string | number; scheduleRunEnvName: string; isSuccess: boolean; @@ -61,6 +61,7 @@ export interface DailyConfig { export interface HourlyConfig { type: RunCycleEnum.HOURLY; + executeAt: Date; intervalHours: number; startTime?: { hour: number; diff --git a/src/modules/views/testflowScheduleRunEmail.handlebars b/src/modules/views/testflowScheduleRunEmail.handlebars index 37c613f37..4abe68f28 100644 --- a/src/modules/views/testflowScheduleRunEmail.handlebars +++ b/src/modules/views/testflowScheduleRunEmail.handlebars @@ -116,7 +116,7 @@ {{else if isFailed}} ❌️ Failed {{else if isPartial}} - ⚠️ Partially Failed({{scheduleRunPassedCount}}/{{scheduleRunTotalRequest}} Requests Failed) + ⚠️ Partially Failed({{scheduleRunFailedCount}}/{{scheduleRunTotalRequest}} Requests Failed) {{/if}}

@@ -126,7 +126,7 @@ @@ -143,7 +143,7 @@ - + @@ -178,11 +178,21 @@
-

Run Summary

+

Run Summary

Pass Rate{{scheduleRunPassPercentage}}{{scheduleRunPassPercentage}}%
Total Duration
- +
- - +
+
+ + + + +
+ +
+
+
+
diff --git a/src/modules/workspace/controllers/testflow.controller.ts b/src/modules/workspace/controllers/testflow.controller.ts index a79f8a896..5f4c75aa0 100644 --- a/src/modules/workspace/controllers/testflow.controller.ts +++ b/src/modules/workspace/controllers/testflow.controller.ts @@ -109,7 +109,7 @@ export class TestflowController { * @description This will retrieve a specific Testflow using its ID, * returning the Testflow object if found. */ - @Get("testflow/:testflowId") + @Get(":workspaceId/testflow/:testflowId") @ApiOperation({ summary: "Get Individual Testflow", description: "This will get individual testflow of a workspace", @@ -121,10 +121,13 @@ export class TestflowController { }) @ApiResponse({ status: 400, description: "Fetch Testflow Request Failed" }) async getTestflow( + @Param("workspaceId") workspaceId: string, @Param("testflowId") testflowId: string, @Res() res: FastifyReply, + @Req() request: ExtendedFastifyRequest, ) { - const testflow = await this.testflowService.getTestflow(testflowId); + const user = request.user; + const testflow = await this.testflowService.getTestflow(workspaceId, testflowId, user._id); const responseData = new ApiResponseService( "Success", HttpStatusCode.OK, @@ -321,8 +324,8 @@ export class TestflowController { const response = await this.testflowService.createTestflowSchedular( createTestflowSchedularDto, user, - ); - const testflow = await this.testflowService.getTestflow(createTestflowSchedularDto.testflowId); + ); + const testflow = await this.testflowService.getTestflow(createTestflowSchedularDto.workspaceId, createTestflowSchedularDto.testflowId, user._id); const result = { testflow, schedule:response @@ -359,7 +362,7 @@ export class TestflowController { workspaceId, user, ); - const testflow = await this.testflowService.getTestflow(testflowId); + const testflow = await this.testflowService.getTestflow(workspaceId, testflowId, user._id); const responseData = new ApiResponseService( "Success", HttpStatusCode.OK, @@ -390,7 +393,7 @@ export class TestflowController { workspaceId, user, ); - const testflow = await this.testflowService.getTestflow(testflowId); + const testflow = await this.testflowService.getTestflow(workspaceId, testflowId, user._id); const responseData = new ApiResponseService( "Success", HttpStatusCode.OK, @@ -421,7 +424,7 @@ export class TestflowController { workspaceId, user, ); - const testflow = await this.testflowService.getTestflow(testflowId); + const testflow = await this.testflowService.getTestflow(workspaceId, testflowId, user._id); const responseData = new ApiResponseService( "Success", HttpStatusCode.OK, @@ -448,7 +451,7 @@ export class TestflowController { ) { const user = request.user; await this.testflowService.deleteScheduleRunHistory(workspaceId, testflowId, scheduleId, runHistoryId, user); - const testflow = await this.testflowService.getTestflow(testflowId); + const testflow = await this.testflowService.getTestflow(workspaceId, testflowId, user._id); const responseData = new ApiResponseService( "Success", HttpStatusCode.OK, diff --git a/src/modules/workspace/repositories/testflow.repository.ts b/src/modules/workspace/repositories/testflow.repository.ts index 7243b9054..f645ce0c1 100644 --- a/src/modules/workspace/repositories/testflow.repository.ts +++ b/src/modules/workspace/repositories/testflow.repository.ts @@ -206,7 +206,7 @@ export class TestflowRepository { schedularId: string, runHistoryItem: TestFlowSchedularRunHistory, ): Promise { - const now = new Date(); + const nowUtc = new Date().toISOString(); if (!testflowId || !schedularId) { throw new Error("Both testflowId and schedularId are required"); } @@ -215,8 +215,8 @@ export class TestflowRepository { { $inc: { "schedules.$[elem].executedCount": 1 }, $set: { - "schedules.$[elem].lastExecuted": now, - updatedAt: now, + "schedules.$[elem].lastExecuted": nowUtc, + updatedAt: nowUtc, }, $push: { "schedules.$[elem].schedularRunHistory": { @@ -231,34 +231,65 @@ export class TestflowRepository { ); } - /** + /** + * Edit a schedular execution (run history item) in a testflow's schedule. + * @param {string} testflowId - The testflow document ID. + * @param {string} schedularId - The schedule ID. + * @param {Partial} updatedRunHistory - The updated fields for the run history item. + * @returns {Promise} - The result of the update operation. + */ + async editSchedularExecution( + testflowId: string, + schedularId: string, + updatedRunHistory: Partial, + ): Promise { + if (!testflowId || !schedularId || !updatedRunHistory.id) { + throw new Error("testflowId, schedularId, and runHistoryId are required"); + } + // Build the update object for only the provided fields + const setObj: Record = {}; + for (const [key, value] of Object.entries(updatedRunHistory)) { + setObj[`schedules.$[elem].schedularRunHistory.$[run].${key}`] = value; + } + setObj["updatedAt"] = new Date(); + return this.db.collection(Collections.TESTFLOW).updateOne( + { _id: new ObjectId(testflowId) }, + { $set: setObj }, + { + arrayFilters: [ + { "elem.id": schedularId }, + { "run.id": updatedRunHistory.id }, + ], + }, + ); + } + + /** * Edit a schedular execution (run history item) in a testflow's schedule. * @param {string} testflowId - The testflow document ID. * @param {string} schedularId - The schedule ID. - * @param {Partial} updatedRunHistory - The updated fields for the run history item. + * @param {Partial} updatedSchedular - The updated fields for the schedule item. * @returns {Promise} - The result of the update operation. */ - async editSchedularExecution( + async editSchedular( testflowId: string, schedularId: string, - updatedRunHistory: Partial, + updatedSchedular: Partial, ): Promise { - if (!testflowId || !schedularId || !updatedRunHistory.id) { - throw new Error("testflowId, schedularId, and runHistoryId are required"); + if (!testflowId || !schedularId) { + throw new Error("testflowId and schedularId are required"); } // Build the update object for only the provided fields const setObj: Record = {}; - for (const [key, value] of Object.entries(updatedRunHistory)) { - setObj[`schedules.$[elem].schedularRunHistory.$[run].${key}`] = value; + for (const [key, value] of Object.entries(updatedSchedular)) { + setObj[`schedules.$[elem].${key}`] = value; } - setObj["updatedAt"] = new Date(); return this.db.collection(Collections.TESTFLOW).updateOne( { _id: new ObjectId(testflowId) }, { $set: setObj }, { arrayFilters: [ { "elem.id": schedularId }, - { "run.id": updatedRunHistory.id }, ], }, ); diff --git a/src/modules/workspace/services/testflow-run.service.ts b/src/modules/workspace/services/testflow-run.service.ts index d4dc848f3..0c4c45d2c 100644 --- a/src/modules/workspace/services/testflow-run.service.ts +++ b/src/modules/workspace/services/testflow-run.service.ts @@ -73,33 +73,35 @@ export class TestflowRunService { testflowId:string, user?: DecodedUserObject, ): Promise => { - // Fetch testflow and environment data - const testflowDetails = await this.testflowRepository.get(testflowId); - const workspaceID = await this.workspaceReposistory.get(workspaceId); - const globalEnvironment = workspaceID.environments[0]; - const globalEnvDetails = await this.environmentReposistory.get( - globalEnvironment.id.toString(), - ); - let environmentData; - if (environmentId) { - environmentData = - await this.environmentReposistory.get(environmentId); - } - const activeVariables = this.combineEnvironmentData( - globalEnvDetails?.variable || [], - environmentData?.variable || [], - ); - // Build proxy URL - const sparrowProxy = this.configService.get("sparrowProxy.baseUrl"); - const proxyUrl = `${sparrowProxy}/proxy/testflow/execute`; - // Prepare request body for proxy API - const body = { - nodes: testflowDetails.nodes || [], - variables: activeVariables || [], - edges: testflowDetails.edges, - userId: user?._id || new ObjectId("000000000000000000000000"), - }; try { + // Fetch testflow and environment data + const testflowDetails = await this.testflowRepository.get(testflowId); + const workspaceID = await this.workspaceReposistory.get(workspaceId); + const globalEnvironment = workspaceID.environments[0]; + const globalEnvDetails = await this.environmentReposistory.get( + globalEnvironment.id.toString(), + ); + let environmentData; + if (environmentId) { + try{ + environmentData = + await this.environmentReposistory.get(environmentId); + }catch(err){} + } + const activeVariables = this.combineEnvironmentData( + globalEnvDetails?.variable || [], + environmentData?.variable || [], + ); + // Build proxy URL + const sparrowProxy = this.configService.get("sparrowProxy.baseUrl"); + const proxyUrl = `${sparrowProxy}/proxy/testflow/execute`; + // Prepare request body for proxy API + const body = { + nodes: testflowDetails.nodes || [], + variables: activeVariables || [], + edges: testflowDetails.edges, + userId: user?._id || new ObjectId("000000000000000000000000"), + }; const response = await axios.post(proxyUrl, body, { headers: { "Content-Type": "application/json", @@ -111,11 +113,18 @@ export class TestflowRunService { nodes:testflowDetails.nodes, edges:testflowDetails.edges, } - // Return only history or any relevant part return finalResult; } catch (error: any) { - console.error("Testflow proxy execution failed:", error.message || error); - throw new Error(error?.message || "Testflow execution failed."); + return { + result:{ + history: { + status: "error" + } + }, + environmentName: "", + nodes: [], + edges: [], + } } }; } \ No newline at end of file diff --git a/src/modules/workspace/services/testflow-schedular.service.ts b/src/modules/workspace/services/testflow-schedular.service.ts index 8d5010363..6fc81fbd2 100644 --- a/src/modules/workspace/services/testflow-schedular.service.ts +++ b/src/modules/workspace/services/testflow-schedular.service.ts @@ -3,12 +3,13 @@ import { SchedulerRegistry } from "@nestjs/schedule"; import { CronJob } from "cron"; import { RunCycleEnum } from "@src/modules/common/enum/testflow.enum"; import { RunCycleConfig } from "@src/modules/common/enum/testflow.enum"; +import { TestflowRepository } from "../repositories/testflow.repository"; @Injectable() export class TestflowSchedulerService { private readonly logger = new Logger(TestflowSchedulerService.name); - constructor(private schedulerRegistry: SchedulerRegistry) {} + constructor(private schedulerRegistry: SchedulerRegistry ) {} /** * Add a cron job @@ -16,30 +17,102 @@ export class TestflowSchedulerService { async addSchedulerJob( runCycle: RunCycleConfig, runApis: (schedularId: string) => Promise, - jobName: string, + afterJobCreate: (cronExpression: string) => void, cronExpression: string, schedularId: string, timezone: string = "UTC", // Always use UTC by default ): Promise { if (!cronExpression) { - console.error(`Invalid run cycle configuration for job ${jobName}`); - this.logger.log(`Invalid run cycle configuration for job ${jobName}`); + this.logger.log(`Invalid run cycle configuration for job ${schedularId}`); return false; } try { - // Create cron job with UTC timezone + // For HOURLY rolling interval, create a one-time cron job for the next execution + if (runCycle.type === RunCycleEnum.HOURLY && runCycle.intervalHours) { + + const scheduleNext = async (lastRun: Date) => { + // Calculate next run time + const nextRun = new Date(lastRun.getTime() + runCycle.intervalHours * 60 * 60 * 1000); + // Generate one-time cron expression for nextRun + const second = nextRun.getUTCSeconds(); + const minute = nextRun.getUTCMinutes(); + const hour = nextRun.getUTCHours(); + const dayOfMonth = nextRun.getUTCDate(); + const month = nextRun.getUTCMonth() + 1; + const oneTimeCron = `${second} ${minute} ${hour} ${dayOfMonth} ${month} *`; + const nextJobName = schedularId; // Always use scheduleId as job name + this.logger.log(`Next rolling interval job ${nextJobName} scheduled for ${nextRun.toISOString()} (cron: ${oneTimeCron})`); + // Remove any existing job with this name before adding + if (this.schedulerRegistry.doesExist("cron", nextJobName)) { + const oldJob = this.schedulerRegistry.getCronJob(nextJobName); + oldJob.stop(); + this.schedulerRegistry.deleteCronJob(nextJobName); + } + const job = new CronJob( + oneTimeCron, + async () => { + this.logger.log(`Executing rolling interval job ${nextJobName} at ${new Date().toISOString()} (UTC)`); + if (runApis) { + try { + await runApis(schedularId); + } catch (error) { + this.logger.error(`Error executing job ${nextJobName}: ${error.message}`, error.stack); + } + } + job.stop(); + this.schedulerRegistry.deleteCronJob(nextJobName); + // Schedule next job + await scheduleNext(nextRun); + }, + null, + false, + timezone, + ); + afterJobCreate(oneTimeCron); + this.schedulerRegistry.addCronJob(nextJobName, job); + job.start(); + }; + // Use the provided initial cron expression for the first run + const nextJobName = schedularId; + // Remove any existing job with this name before adding + if (this.schedulerRegistry.doesExist("cron", nextJobName)) { + const oldJob = this.schedulerRegistry.getCronJob(nextJobName); + oldJob.stop(); + this.schedulerRegistry.deleteCronJob(nextJobName); + } + const job = new CronJob( + cronExpression, + async () => { + if (runApis) { + try { + await runApis(schedularId); + } catch (error) { + this.logger.error(`Error executing job ${nextJobName}: ${error.message}`, error.stack); + } + } + job.stop(); + this.schedulerRegistry.deleteCronJob(nextJobName); + // After first run, start rolling with scheduleNext + await scheduleNext(new Date()); + }, + null, + false, + timezone, + ); + this.schedulerRegistry.addCronJob(nextJobName, job); + job.start(); + return true; + } + // Default: Create cron job with UTC timezone const job = new CronJob( cronExpression, async () => { - this.logger.log( - `Executing job ${jobName} at ${new Date().toISOString()} (UTC)` - ); if (runApis) { try { await runApis(schedularId); } catch (error) { this.logger.error( - `Error executing job ${jobName}: ${error.message}`, + `Error executing job ${schedularId}: ${error.message}`, error.stack ); } @@ -47,24 +120,19 @@ export class TestflowSchedulerService { // Handle one-time jobs if (runCycle.type === RunCycleEnum.ONCE) { job.stop(); - this.schedulerRegistry.deleteCronJob(jobName); - this.logger.log(`One-time scheduler ${jobName} completed and removed`); + this.schedulerRegistry.deleteCronJob(schedularId); } }, null, // onComplete callback false, // start - we'll call start() manually timezone, // Set timezone to UTC ); - this.schedulerRegistry.addCronJob(jobName, job); + this.schedulerRegistry.addCronJob(schedularId, job); job.start(); - this.logger.log( - `Scheduler job ${jobName} registered with cycle: ${runCycle.type}, ` + - `timezone: ${timezone}, cron: ${cronExpression}` - ); return true; } catch (error) { this.logger.error( - `Failed to create scheduler job ${jobName}: ${error.message}`, + `Failed to create scheduler job ${schedularId}: ${error.message}`, error.stack ); return false; @@ -82,9 +150,6 @@ export class TestflowSchedulerService { job.stop(); this.schedulerRegistry.deleteCronJob(jobName); } - this.logger.log( - `Scheduler job ${jobName} (ID: ${schedulerId}) removed from DB + registry`, - ); return true; } catch (error) { this.logger.warn( @@ -102,7 +167,6 @@ export class TestflowSchedulerService { if (this.schedulerRegistry.doesExist("cron", jobName)) { const job = this.schedulerRegistry.getCronJob(jobName); job.stop(); - this.logger.log(`Scheduler job ${jobName} paused`); } } @@ -114,11 +178,14 @@ export class TestflowSchedulerService { if (this.schedulerRegistry.doesExist("cron", jobName)) { const job = this.schedulerRegistry.getCronJob(jobName); job.start(); - this.logger.log(`Scheduler job ${jobName} resumed`); } } private generateJobName(schedulerId: string): string { - return `scheduler_${schedulerId}`; + return `${schedulerId}`; + } + + public logAllCronJobs() { + return Array.from(this.schedulerRegistry.getCronJobs().keys()); } } diff --git a/src/modules/workspace/services/testflow.service.ts b/src/modules/workspace/services/testflow.service.ts index e5bda4f6e..129e94040 100644 --- a/src/modules/workspace/services/testflow.service.ts +++ b/src/modules/workspace/services/testflow.service.ts @@ -47,6 +47,7 @@ import { v4 as uuidv4 } from "uuid"; import { TestflowSchedulerService } from "./testflow-schedular.service"; import { DailyConfig, + DayOfWeek, EmailData, HourlyConfig, NotificationReceiveType, @@ -62,6 +63,7 @@ import { Logger } from "@nestjs/common"; import { OnModuleInit } from "@nestjs/common"; import { UserRepository } from "@src/modules/identity/repositories/user.repository"; import { EnvironmentRepository } from "../repositories/environment.repository"; +import { Collections } from "@src/modules/common/enum/database.collection.enum"; /** * Testflow Service @@ -82,31 +84,85 @@ export class TestflowService implements OnModuleInit { private readonly environmentReposistory: EnvironmentRepository, ) {} + async getNextFutureCronExpression(pastCron: string, intervalHours: number): Promise { + // Expecting cron in format: 's m h * * *' + const parts = pastCron.trim().split(/\s+/); + if (parts.length !== 6) return pastCron; + + let second = parseInt(parts[0], 10); + let minute = parseInt(parts[1], 10); + let hour = parseInt(parts[2], 10); + let day = parseInt(parts[3], 10); + let month = parseInt(parts[4], 10) - 1; + + // Start from the past time + let now = new Date(); + let next = new Date(Date.UTC( + now.getUTCFullYear(), + month, + day, + hour, + minute, + second, + 0 + )); + + // If the past time is in the past, keep adding interval until it's in the future + while (next <= now) { + next.setUTCHours(next.getUTCHours() + intervalHours); + } + + // Return new cron expression + return `${next.getUTCSeconds()} ${next.getUTCMinutes()} ${next.getUTCHours()} ${next.getUTCDate()} ${next.getUTCMonth() + 1} *`; + } + async onModuleInit() { - this.logger.log("Bootstrapping schedulers from DB..."); - const testflows = await this.testflowRepository.getAll(); - for (const tf of testflows) { - if (!tf.schedules?.length) continue; - for (const schedule of tf.schedules) { - const runCycleConfig = this.buildRunCycleConfig( - schedule.runConfiguration, - ); - if (schedule.isActive && schedule.cronExpression) { - await this.testflowSchedulerService.addSchedulerJob( - runCycleConfig, - this.getScheduledExecutionCallback( - tf._id.toString(), - schedule.environmentId, - tf.workspaceId, - schedule.id, - ), - schedule.schedularName, - schedule.cronExpression, - schedule.id, - "UTC", - ); + try { + this.logger.log("Bootstrapping schedulers from DB..."); + const testflows = await this.testflowRepository.getAll(); + if (testflows.length === 0) { + this.logger.log("No testflows found — skipping scheduler bootstrap."); + return; + } + for (const tf of testflows) { + if (!tf.schedules?.length) continue; + + for (const schedule of tf.schedules) { + try{ + const runCycleConfig = this.buildRunCycleConfig( + schedule.runConfiguration, + ); + if (schedule.isActive && schedule.cronExpression) { + let cronExpression = schedule.cronExpression; + if(schedule.runConfiguration.runCycle === RunCycleEnum.HOURLY){ + const intervalHours = schedule.runConfiguration.intervalHours; + cronExpression = await this.getNextFutureCronExpression(cronExpression, intervalHours); + } + await this.testflowSchedulerService.addSchedulerJob( + runCycleConfig, + this.getScheduledExecutionCallback( + tf._id.toString(), + schedule.environmentId, + tf.workspaceId, + schedule.id, + ), + (_cronExpression: string)=>{ + this.testflowRepository.editSchedular(tf._id.toString(), schedule.id, { + cronExpression: _cronExpression, + }); + }, + cronExpression, + schedule.id, + "UTC", + ); + } + }catch(error){ + this.logger.error("Error adding scheduler job:", error); + } } } + } catch (error) { + this.logger.error("Error during scheduler bootstrap:", error); } } @@ -152,11 +208,36 @@ export class TestflowService implements OnModuleInit { throw new NotFoundException("Schedule not found"); } - let environmentName = ""; - if(updateScheduleDto?.environmentId){ - const environmentData = await this.environmentReposistory.get(updateScheduleDto?.environmentId); - environmentName = environmentData?.name || ""; + let environmentName = ""; + if (updateScheduleDto?.environmentId) { + const environmentData = await this.environmentReposistory.get( + updateScheduleDto?.environmentId, + ); + environmentName = environmentData?.name || ""; + } + + if(updateScheduleDto.runConfiguration){ + const runCycleConfig = this.buildRunCycleConfig(updateScheduleDto.runConfiguration); + const cronExpression = this.generateCronExpression(runCycleConfig); + if (!cronExpression) { + updateScheduleDto.cronExpression = null; + } + else{ + updateScheduleDto.cronExpression = cronExpression; } + }else{ + if(existingSchedular.runConfiguration.runCycle === RunCycleEnum.HOURLY){ + const runCycleConfig = this.buildRunCycleConfig(existingSchedular.runConfiguration); + const cronExpression = this.generateCronExpression(runCycleConfig); + if (!cronExpression) { + updateScheduleDto.cronExpression = null; + } + else{ + updateScheduleDto.cronExpression = cronExpression; + } + } + } + // Merge update fields, ensure id is present const updatedSchedular: TestflowSchedular = { ...existingSchedular, @@ -172,43 +253,42 @@ export class TestflowService implements OnModuleInit { scheduleId, updatedSchedular, ); - // Optionally update cron job if runConfiguration or isActive changed - if ( - updateScheduleDto.runConfiguration || - updateScheduleDto.isActive !== undefined - ) { - const schedular = await this.testflowRepository.getSchedularById( - testflowId, - scheduleId, - ); - if (schedular) { - // Remove old job - await this.testflowSchedulerService.removeSchedulerJob(scheduleId); - // If still active, re-add job - if (schedular.isActive) { - const runCycleConfig = this.buildRunCycleConfig( - schedular.runConfiguration, - ); - const cronExpression = - schedular.cronExpression || - this.generateCronExpression(runCycleConfig); - await this.testflowSchedulerService.addSchedulerJob( - runCycleConfig, - this.getScheduledExecutionCallback( - testflowId, - schedular.environmentId, - workspaceId, - scheduleId, - user, - ), - schedular.schedularName, - cronExpression, + + const schedular = await this.testflowRepository.getSchedularById( + testflowId, + scheduleId, + ); + if (schedular) { + // Remove old job + await this.testflowSchedulerService.removeSchedulerJob(scheduleId); + // If still active, re-add job + if (schedular.isActive) { + const runCycleConfig = this.buildRunCycleConfig( + schedular.runConfiguration, + ); + const cronExpression = schedular.cronExpression; + await this.testflowSchedulerService.addSchedulerJob( + runCycleConfig, + this.getScheduledExecutionCallback( + testflowId, + schedular.environmentId, + workspaceId, scheduleId, - ); - } + user, + ), + (_cronExpression: string)=>{ + this.testflowRepository.editSchedular(testflowId, scheduleId, { + cronExpression: _cronExpression, + }); + }, + cronExpression, + scheduleId, + "UTC", + ); } } - return result; + + return result; } /** @@ -317,8 +397,9 @@ export class TestflowService implements OnModuleInit { * Fetches single testflow. * @param id - Testflow id you want to fetch. */ - async getTestflow(id: string): Promise> { - return await this.testflowRepository.get(id); + async getTestflow(workspaceId: string, testflowId: string, userId: ObjectId): Promise> { + await this.checkPermission(workspaceId, userId); + return await this.testflowRepository.get(testflowId); } /** @@ -328,6 +409,9 @@ export class TestflowService implements OnModuleInit { */ async checkPermission(workspaceId: string, userid: ObjectId): Promise { const workspace = await this.workspaceService.get(workspaceId); + if(workspace.workspaceType === WorkspaceType.PUBLIC){ + return; + } const hasPermission = workspace.users.some((user) => { return user.id.toString() === userid.toString(); }); @@ -357,6 +441,16 @@ export class TestflowService implements OnModuleInit { id, user._id, ); + + // Remove all associated cronjobs for this testflow + if (testflow?.schedules && Array.isArray(testflow.schedules)) { + for (const schedule of testflow.schedules) { + if (schedule?.id) { + await this.testflowSchedulerService.removeSchedulerJob(schedule.id); + } + } + } + const updateMessage = `"${testflow.name}" testflow is deleted from "${workspace.name}" workspace`; const currentWorkspaceObject = new ObjectId(workspaceId); const updateWorkspaceData: Partial = { @@ -489,26 +583,7 @@ export class TestflowService implements OnModuleInit { user: DecodedUserObject, ) { try { - const workspaceUsers = await this.workspaceReposistory.get( - schedularData?.workspaceId, - ); - if (!workspaceUsers) { - throw new NotFoundException("Workspace not found."); - } - const userDetails = workspaceUsers.users.find( - (item) => item.id === user._id.toString(), - ); - if (!userDetails) { - throw new NotFoundException("User not found in workspace."); - } - if ( - userDetails.role !== WorkspaceRole.ADMIN && - userDetails.role !== WorkspaceRole.EDITOR - ) { - throw new ForbiddenException( - "User does not have permission to perform this action.", - ); - } + await this.isWorkspaceAdminorEditor(schedularData?.workspaceId, user._id); // Build cron config const runCycleConfig = this.buildRunCycleConfig( schedularData.runConfiguration, @@ -521,8 +596,10 @@ export class TestflowService implements OnModuleInit { throw new BadRequestException("Invalid run cycle configuration"); } let environmentName = ""; - if(schedularData?.environmentId){ - const environmentData = await this.environmentReposistory.get(schedularData?.environmentId); + if (schedularData?.environmentId) { + const environmentData = await this.environmentReposistory.get( + schedularData?.environmentId, + ); environmentName = environmentData?.name || ""; } // Save scheduler details in DB @@ -557,7 +634,11 @@ export class TestflowService implements OnModuleInit { schedulerId, user, ), - jobName, + (_cronExpression: string)=>{ + this.testflowRepository.editSchedular(schedularData.testflowId, schedulerId, { + cronExpression: _cronExpression, + }); + }, cronExpression, schedulerId, "UTC", @@ -608,6 +689,7 @@ export class TestflowService implements OnModuleInit { } return { type: RunCycleEnum.HOURLY, + executeAt: new Date(Date.now() + 1 * 60 * 1000), intervalHours: runConfig.intervalHours, startTime: runConfig.time ? this.parseTime(runConfig.time) @@ -694,23 +776,33 @@ export class TestflowService implements OnModuleInit { } private generateHourlyCronExpression(config: HourlyConfig): string { - const { intervalHours, startTime } = config; - if (startTime) { - const { hour, minute, second = 0 } = startTime; - return `${second} ${minute} ${hour}-23/${intervalHours} * * *`; - } else { - const now = new Date(); - const utcHour = now.getUTCHours(); - const utcMinute = now.getUTCMinutes(); - const utcSecond = now.getUTCSeconds(); - return `${utcSecond} ${utcMinute} ${utcHour}-23/${intervalHours} * * *`; + const executeAt = config.executeAt; + const now = new Date(); + if (executeAt <= now) { + return null; } + const second = executeAt.getUTCSeconds(); + const minute = executeAt.getUTCMinutes(); + const hour = executeAt.getUTCHours(); + const dayOfMonth = executeAt.getUTCDate(); + const month = executeAt.getUTCMonth() + 1; + return `${second} ${minute} ${hour} ${dayOfMonth} ${month} *`; } private generateWeeklyCronExpression(config: WeeklyConfig): string { const { days, time } = config; const { hour, minute, second = 0 } = time; - return `${second} ${minute} ${hour} * * ${days.join(",")}`; + const validDays = days.filter((day) => { + return day >= DayOfWeek.SUNDAY && day <= DayOfWeek.SATURDAY; + }); + if (validDays.length === 0) { + throw new BadRequestException( + "Invalid days specified. Days must be valid DayOfWeek values (0=Sunday, 1=Monday, ..., 6=Saturday)", + ); + } + const sortedDays = validDays.sort((a, b) => a - b); + const daysString = sortedDays.join(","); + return `${second} ${minute} ${hour} * * ${daysString}`; } public getScheduledExecutionCallback( @@ -748,12 +840,12 @@ export class TestflowService implements OnModuleInit { isScheduled, status: "pending", requests: [], - responses:[], + responses: [], nodes: [], edges: [], failedRequests: 0, successRequests: 0, - totalTime:"0 ms", + totalTime: "0 ms", createdAt: new Date(), }; //Save execution result in DB @@ -774,6 +866,7 @@ export class TestflowService implements OnModuleInit { nodes: response.nodes, edges: response.edges, ...response.result.history, + status: response?.result?.history?.status || "error", }; //Save execution result in DB await this.testflowRepository.editSchedularExecution( @@ -792,13 +885,19 @@ export class TestflowService implements OnModuleInit { false, ); } - const data = response.result.history; + const data = response?.result?.history; let scheduleRunResult; - if (data.status === "fail" && data.successRequests < 1) { + if(!response?.status){ + scheduleRunResult = "error"; + } + else if (data?.status === "fail" && data?.successRequests < 1) { scheduleRunResult = "failed"; - } else if (data.status === "success") { + } else if (data?.status === "success") { scheduleRunResult = "success"; - } else { + } else if (data?.status === "error") { + scheduleRunResult = "error"; + } + else { scheduleRunResult = "partial"; } const totalRequestCount = data.successRequests + data.failedRequests; @@ -806,16 +905,27 @@ export class TestflowService implements OnModuleInit { data.createdBy, ); const successPercentage = - (data.successRequests / totalRequestCount) * 100; + Math.round((data.successRequests / totalRequestCount) * 100 * 100) / + 100; const emailData: EmailData = { userName: userDetails?.name, scheduleName: getSchedular.name, - scheduleLastestRun: new Date(getSchedular.lastExecuted), + scheduleLastestRun: + new Date(getSchedular.lastExecuted).toLocaleString("en-US", { + timeZone: "UTC", + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }) + " UTC", scheduleRunResult: scheduleRunResult, scheduleRunPassedCount: data.successRequests, scheduleRunFailedCount: data.failedRequests, scheduleRunTotalRequest: data.successRequests + data.failedRequests, - scheduleRunPassPercentage: successPercentage, + scheduleRunPassPercentage: successPercentage.toString(), scheduleTotalTime: data.totalTime, scheduleRunEnvName: response.environmentName, isSuccess: data.successRequests === totalRequestCount,