Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(api): new test-data endpoint to pass dynamic data #6871

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/api/src/app/workflows-v2/shared/build-string-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { JSONSchema } from 'json-schema-to-ts';

/**
* Builds a JSON schema object where each variable becomes a string property.
*/
export function buildJSONSchema(variables: Record<string, unknown>): JSONSchema {
const properties: Record<string, JSONSchema> = {};

for (const [variableKey, variableValue] of Object.entries(variables)) {
properties[variableKey] = {
type: 'string',
default: variableValue,
};
}

return {
type: 'object',
properties,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { mapStepTypeToResult } from '../../shared';
import { GetWorkflowByIdsUseCase } from '../get-workflow-by-ids/get-workflow-by-ids.usecase';
import { InvalidStepException } from '../../exceptions/invalid-step.exception';
import { BuildDefaultPayloadUseCase } from '../build-payload-from-placeholder';
import { buildJSONSchema } from '../../shared/build-string-schema';

@Injectable()
export class GetStepDataUsecase {
Expand Down Expand Up @@ -39,12 +40,12 @@ export class GetStepDataUsecase {
};
}

private buildPayloadSchema(controlValues: Record<string, any>) {
private buildPayloadSchema(controlValues: Record<string, unknown>) {
const payloadVariables = this.buildDefaultPayloadUseCase.execute({
controlValues,
}).previewPayload.payload;

return buildStringSchema(payloadVariables || {});
return buildJSONSchema(payloadVariables || {});
}

private async fetchWorkflow(command: GetStepDataCommand) {
Expand Down Expand Up @@ -158,22 +159,3 @@ function buildPreviousStepsSchema(previousSteps: NotificationStepEntity[] | unde
description: 'Previous Steps Results',
} as const satisfies JSONSchema;
}

/**
* Builds a JSON schema object where each variable becomes a string property.
*/
function buildStringSchema(variables: Record<string, unknown>): JSONSchema {
const properties: Record<string, JSONSchema> = {};

for (const [variableKey, variableValue] of Object.entries(variables)) {
properties[variableKey] = {
type: 'string',
default: variableValue,
};
}

return {
type: 'object',
properties,
};
}
108 changes: 70 additions & 38 deletions apps/api/src/app/workflows-v2/usecases/test-data/test-data.usecase.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,89 @@
import { JSONSchema } from 'json-schema-to-ts';
import { Injectable } from '@nestjs/common';
import { NotificationTemplateEntity } from '@novu/dal';
import { UserSessionData, WorkflowTestDataResponseDto } from '@novu/shared';
import { ControlValuesRepository, NotificationStepEntity, NotificationTemplateEntity } from '@novu/dal';
import { ControlValuesLevelEnum, StepTypeEnum, UserSessionData, WorkflowTestDataResponseDto } from '@novu/shared';

import { WorkflowTestDataCommand } from './test-data.command';
import { GetWorkflowByIdsUseCase } from '../get-workflow-by-ids/get-workflow-by-ids.usecase';
import { GetWorkflowByIdsCommand } from '../get-workflow-by-ids/get-workflow-by-ids.command';

const buildToFieldSchema = ({ user }: { user: UserSessionData }) =>
({
type: 'object',
properties: {
subscriberId: { type: 'string', default: user._id },
/*
* TODO: the email and phone fields should be dynamic based on the workflow steps
* if the workflow has has an email step, then email is required etc
*/
email: { type: 'string', default: user.email ?? '', format: 'email' },
phone: { type: 'string', default: '' },
},
required: ['subscriberId', 'email', 'phone'],
additionalProperties: false,
}) as const satisfies JSONSchema;

const buildPayloadSchema = () =>
({
type: 'object',
description: 'Schema representing the workflow payload',
properties: {
/*
* TODO: the properties should be dynamic based on the workflow variables
*/
example: { type: 'string', description: 'Example field', default: 'payload.example' },
},
required: ['subscriberId', 'email', 'phone'],
additionalProperties: false,
}) as const satisfies JSONSchema;
import { BuildDefaultPayloadUseCase } from '../build-payload-from-placeholder';
import { buildJSONSchema } from '../../shared/build-string-schema';

@Injectable()
export class WorkflowTestDataUseCase {
constructor(private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase) {}
constructor(
private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase,
private controlValuesRepository: ControlValuesRepository,
private buildDefaultPayloadUseCase: BuildDefaultPayloadUseCase
) {}

async execute(command: WorkflowTestDataCommand): Promise<WorkflowTestDataResponseDto> {
const _workflowEntity: NotificationTemplateEntity | null = await this.getWorkflowByIdsUseCase.execute(
const _workflowEntity: NotificationTemplateEntity = await this.fetchWorkflow(command);
const toSchema = buildToFieldSchema({ user: command.user, steps: _workflowEntity.steps });
const payloadSchema = await this.buildPayloadSchema(command, _workflowEntity);

return {
to: toSchema,
payload: payloadSchema,
};
}

private async fetchWorkflow(command: WorkflowTestDataCommand): Promise<NotificationTemplateEntity> {
return await this.getWorkflowByIdsUseCase.execute(
GetWorkflowByIdsCommand.create({
...command,
identifierOrInternalId: command.identifierOrInternalId,
})
);
}

return {
to: buildToFieldSchema({ user: command.user }),
payload: buildPayloadSchema(),
};
private async buildPayloadSchema(command: WorkflowTestDataCommand, _workflowEntity: NotificationTemplateEntity) {
let payloadVariables: Record<string, unknown> = {};
for (const step of _workflowEntity.steps) {
const newValues = await this.getValues(command.user, step._templateId, _workflowEntity._id);

/*
* we need to build the payload defaults for each step,
* because of possible duplicated values (like subject, body, etc...)
*/
const currPayloadVariables = this.buildDefaultPayloadUseCase.execute({
controlValues: newValues,
}).previewPayload.payload;
payloadVariables = { ...payloadVariables, ...currPayloadVariables };
}

return buildJSONSchema(payloadVariables || {});
}

private async getValues(user: UserSessionData, _stepId: string, _workflowId: string) {
const controlValuesEntity = await this.controlValuesRepository.findOne({
_environmentId: user.environmentId,
_organizationId: user.organizationId,
_workflowId,
_stepId,
level: ControlValuesLevelEnum.STEP_CONTROLS,
});

return controlValuesEntity?.controls || {};
}
}

const buildToFieldSchema = ({ user, steps }: { user: UserSessionData; steps: NotificationStepEntity[] }) => {
const isEmailExist = isContainsStepType(steps, StepTypeEnum.EMAIL);
const isSmsExist = isContainsStepType(steps, StepTypeEnum.SMS);

return {
type: 'object',
properties: {
subscriberId: { type: 'string', default: user._id },
...(isEmailExist ? { email: { type: 'string', default: user.email ?? '', format: 'email' } } : {}),
...(isSmsExist ? { phone: { type: 'string', default: '' } } : {}),
},
required: ['subscriberId', ...(isEmailExist ? ['email'] : []), ...(isSmsExist ? ['phone'] : [])],
additionalProperties: false,
} as const satisfies JSONSchema;
};

function isContainsStepType(steps: NotificationStepEntity[], type: StepTypeEnum) {
return steps.some((step) => step.template?.type === type);
}
76 changes: 76 additions & 0 deletions apps/api/src/app/workflows-v2/workflow.controller.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,70 @@ describe('Workflow Controller E2E API Testing', () => {
});
});

describe('Get Test Data Permutations', () => {
it('should get test data', async () => {
const steps = [
{
...buildEmailStep(),
controlValues: {
body: 'Welcome to our newsletter {{bodyText}}{{bodyText2}}{{payload.emailPrefixBodyText}}',
subject: 'Welcome to our newsletter {{subjectText}} {{payload.prefixSubjectText}}',
},
},
{ ...buildInAppStep(), controlValues: { subject: 'Welcome to our newsletter {{payload.inAppSubjectText}}' } },
];
const createWorkflowDto: CreateWorkflowDto = buildCreateWorkflowDto('', { steps });
const res = await session.testAgent.post(`${v2Prefix}/workflows`).send(createWorkflowDto);
expect(res.status).to.be.equal(201);
const workflowCreated: WorkflowResponseDto = res.body.data;
const workflowTestData = await getWorkflowTestData(workflowCreated._id);

expect(workflowTestData).to.be.ok;
expect(workflowTestData.payload).to.deep.equal({
type: 'object',
properties: {
emailPrefixBodyText: {
type: 'string',
default: '{{payload.emailPrefixBodyText}}',
},
prefixSubjectText: {
type: 'string',
default: '{{payload.prefixSubjectText}}',
},
inAppSubjectText: {
type: 'string',
default: '{{payload.inAppSubjectText}}',
},
},
});

/*
* Validate the 'to' schema
* Note: Can't use deep comparison since emails differ between local and CI environments due to user sessions
*/
const toSchema = workflowTestData.to;
if (
typeof toSchema === 'boolean' ||
typeof toSchema.properties?.subscriberId === 'boolean' ||
typeof toSchema.properties?.email === 'boolean'
) {
expect((toSchema as any).type).to.be.a('boolean');
expect(((toSchema as any).properties?.subscriberId as any).type).to.be.a('boolean');
expect(((toSchema as any).properties?.email as any).type).to.be.a('boolean');
throw new Error('To schema is not a boolean');
}
expect(toSchema.type).to.equal('object');
expect(toSchema.properties?.subscriberId.type).to.equal('string');
expect(toSchema.properties?.subscriberId.default).to.equal(session.user._id);
expect(toSchema.properties?.email.type).to.equal('string');
expect(toSchema.properties?.email.format).to.equal('email');
expect(toSchema.properties?.email.default).to.be.a('string');
expect(toSchema.properties?.email.default).to.not.equal('');
expect(toSchema.required).to.deep.equal(['subscriberId', 'email']);
expect(toSchema.additionalProperties).to.be.false;
});
});

async function updateWorkflowRest(id: string, workflow: UpdateWorkflowDto): Promise<WorkflowResponseDto> {
const novuRestResult = await workflowsClient.updateWorkflow(id, workflow);
if (novuRestResult.isSuccessResult()) {
Expand Down Expand Up @@ -522,6 +586,18 @@ describe('Workflow Controller E2E API Testing', () => {
return value;
}

async function getWorkflowTestData(workflowId: string, envId?: string) {
const novuRestResult = await createWorkflowClient(session.serverUrl, getHeaders(envId)).getWorkflowTestData(
workflowId
);
if (!novuRestResult.isSuccessResult()) {
throw new Error(novuRestResult.error!.responseText);
}
const { value } = novuRestResult;

return value;
}

async function getWorkflowStepControlValues(
workflow: WorkflowResponseDto,
step: StepDto & { _id: string; slug: Slug; stepId: string },
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/clients/workflows-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const createWorkflowClient = (baseUrl: string, headers: HeadersInit = {})
): Promise<NovuRestResult<StepDataDto, HttpError>> => {
return await baseClient.safeGet<StepDataDto>(`/v2/workflows/${workflowId}/steps/${stepId}`);
};

const deleteWorkflow = async (workflowId: string): Promise<NovuRestResult<void, HttpError>> => {
return await baseClient.safeDelete(`/v2/workflows/${workflowId}`);
};
Expand Down
1 change: 0 additions & 1 deletion packages/shared/src/dto/workflows/create-workflow-dto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { IsDefined, IsNotEmpty, IsString } from 'class-validator';
import { PreferencesRequestDto, StepCreateDto, WorkflowCommonsFields } from './workflow-commons-fields';
import { WorkflowCreationSourceEnum } from '../../types';

Expand Down
Loading