Skip to content
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
2 changes: 2 additions & 0 deletions redisinsight/api/config/ormconfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { FeaturesConfigEntity } from 'src/modules/feature/entities/features-conf
import { CloudDatabaseDetailsEntity } from 'src/modules/cloud/database/entities/cloud-database-details.entity';
import { CloudCapiKeyEntity } from 'src/modules/cloud/capi-key/entity/cloud-capi-key.entity';
import { RdiEntity } from 'src/modules/rdi/entities/rdi.entity';
import { PipelineDraftEntity } from 'src/modules/rdi/entities/pipeline-draft.entity';
import { AiQueryMessageEntity } from 'src/modules/ai/query/entities/ai-query.message.entity';
import { CloudSessionEntity } from 'src/modules/cloud/session/entities/cloud.session.entity';
import { DatabaseSettingsEntity } from 'src/modules/database-settings/entities/database-setting.entity';
Expand Down Expand Up @@ -54,6 +55,7 @@ const ormConfig = {
CloudDatabaseDetailsEntity,
CloudCapiKeyEntity,
RdiEntity,
PipelineDraftEntity,
AiQueryMessageEntity,
CloudSessionEntity,
DatabaseSettingsEntity,
Expand Down
27 changes: 27 additions & 0 deletions redisinsight/api/migration/1784100000000-pipeline-draft.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class PipelineDraft1784100000000 implements MigrationInterface {
name = 'PipelineDraft1784100000000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "pipeline_draft" (
"id" varchar PRIMARY KEY NOT NULL,
"rdiInstanceId" varchar NOT NULL,
"data" text NOT NULL,
"encryption" varchar,
"createdAt" datetime NOT NULL DEFAULT (datetime('now')),
"updatedAt" datetime NOT NULL DEFAULT (datetime('now')),
CONSTRAINT "FK_pipeline_draft_rdiInstanceId" FOREIGN KEY ("rdiInstanceId") REFERENCES "rdi" ("id") ON DELETE CASCADE ON UPDATE NO ACTION
)`,
);
await queryRunner.query(
`CREATE INDEX "IDX_pipeline_draft_rdiInstanceId" ON "pipeline_draft" ("rdiInstanceId")`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_pipeline_draft_rdiInstanceId"`);
await queryRunner.query(`DROP TABLE "pipeline_draft"`);
}
}
2 changes: 2 additions & 0 deletions redisinsight/api/migration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { DatabaseIsProduction1778758000000 } from './1778758000000-database-isPr
import { Environment1779000000000 } from './1779000000000-database-environment';
import { DropDatabaseIsProduction1779000000001 } from './1779000000001-drop-database-isProduction';
import { DatabaseConnectionFamily1784000000000 } from './1784000000000-database-connection-family';
import { PipelineDraft1784100000000 } from './1784100000000-pipeline-draft';
import { AzureVerifyServerCert1785100000000 } from './1785100000000-azure-verify-server-cert';

export default [
Expand Down Expand Up @@ -125,5 +126,6 @@ export default [
Environment1779000000000,
DropDatabaseIsProduction1779000000001,
DatabaseConnectionFamily1784000000000,
PipelineDraft1784100000000,
AzureVerifyServerCert1785100000000,
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Factory } from 'fishery';
import { faker } from '@faker-js/faker';
import { PipelineDraft } from '../models/pipeline-draft';
import { PipelineDraftEntity } from '../entities/pipeline-draft.entity';
import { CreatePipelineDraftDto } from '../dto/create.pipeline-draft.dto';

export const pipelineDraftFactory = Factory.define<PipelineDraft>(() => ({
id: faker.string.uuid(),
rdiInstanceId: faker.string.uuid(),
data: { [faker.word.noun()]: faker.word.words(3) },
createdAt: faker.date.recent(),
updatedAt: faker.date.recent(),
}));

// Entity-level `data` is the persisted string form (what DataAsJsonString
// produces before encryption), not the parsed object the API model exposes.
export const pipelineDraftEntityFactory = Factory.define<PipelineDraftEntity>(
() => {
const draft = pipelineDraftFactory.build();

return {
...draft,
data: JSON.stringify(draft.data),
};
},
);

export const createPipelineDraftDtoFactory =
Factory.define<CreatePipelineDraftDto>(() => {
const { data } = pipelineDraftFactory.build();

return { data };
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { OmitType } from '@nestjs/swagger';
import { PipelineDraft } from 'src/modules/rdi/models';

export class CreatePipelineDraftDto extends OmitType(PipelineDraft, [
'id',
'rdiInstanceId',
'createdAt',
'updatedAt',
] as const) {}
2 changes: 2 additions & 0 deletions redisinsight/api/src/modules/rdi/dto/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export * from './create.rdi.dto';
export * from './update.rdi.dto';
export * from './create.pipeline-draft.dto';
export * from './update.pipeline-draft.dto';
export * from './rdi.dry-run.job.dto';
export * from './rdi.dry-run.job.response.dto';
export * from './rdi-test-connections.response.dto';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsObject, ValidateIf } from 'class-validator';

export class UpdatePipelineDraftDto {
@ApiPropertyOptional({
description: 'Draft data as a JSON object. Structure is not validated.',
type: Object,
})
// skip validation only when omitted, not when explicitly null
@ValidateIf((dto) => dto.data !== undefined)
@IsNotEmpty()
@IsObject()
data?: object;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Expose } from 'class-transformer';
import { DataAsJsonString } from 'src/common/decorators';
import { RdiEntity } from 'src/modules/rdi/entities/rdi.entity';

@Entity('pipeline_draft')
export class PipelineDraftEntity {
@Expose()
@PrimaryGeneratedColumn('uuid')
id: string;

@Expose()
@Index('IDX_pipeline_draft_rdiInstanceId')
@Column({ nullable: false })
rdiInstanceId: string;
Comment thread
ArtemHoruzhenko marked this conversation as resolved.

@ManyToOne(() => RdiEntity, { nullable: false, onDelete: 'CASCADE' })
@JoinColumn({ name: 'rdiInstanceId' })
rdiInstance?: RdiEntity;

@Column({ nullable: false, type: 'text' })
@DataAsJsonString()
@Expose()
data: string;

@Column({ nullable: true })
encryption?: string;

@Expose()
@CreateDateColumn()
createdAt: Date;

@Expose()
@UpdateDateColumn()
updatedAt: Date;
}
1 change: 1 addition & 0 deletions redisinsight/api/src/modules/rdi/models/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './rdi.client.metadata';
export * from './rdi';
export * from './pipeline-draft';
export * from './rdi-pipeline';
export * from './rdi-dry-run';
export * from './rdi-statistics';
Expand Down
42 changes: 42 additions & 0 deletions redisinsight/api/src/modules/rdi/models/pipeline-draft.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose } from 'class-transformer';
import { IsNotEmpty, IsObject } from 'class-validator';

export class PipelineDraft {
@ApiProperty({
description: 'Pipeline draft id.',
type: String,
})
@Expose()
id: string;

@ApiProperty({
description: 'Id of the RDI instance this draft belongs to.',
type: String,
})
@Expose()
rdiInstanceId: string;

@ApiProperty({
description: 'Draft data as a JSON object. Structure is not validated.',
type: Object,
})
@Expose()
@IsNotEmpty()
@IsObject()
data: object;

@ApiProperty({
description: 'Time the draft was created.',
type: Date,
})
@Expose()
createdAt: Date;

@ApiProperty({
description: 'Time the draft was last updated.',
type: Date,
})
@Expose()
updatedAt: Date;
}
172 changes: 172 additions & 0 deletions redisinsight/api/src/modules/rdi/pipeline-draft.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { Test, TestingModule } from '@nestjs/testing';
import { faker } from '@faker-js/faker';
import { NotFoundException, ValidationPipe } from '@nestjs/common';
import { mockSessionMetadata } from 'src/__mocks__';
import { PipelineDraftController } from './pipeline-draft.controller';
import { PipelineDraftService } from './pipeline-draft.service';
import { pipelineDraftFactory } from './__tests__/pipeline-draft.factory';
import { CreatePipelineDraftDto, UpdatePipelineDraftDto } from './dto';

const mockRdiInstanceId = faker.string.uuid();

const mockRdiClientMetadata = {
sessionMetadata: mockSessionMetadata,
id: mockRdiInstanceId,
};

const mockPipelineDraftService = () => ({
create: jest.fn(),
list: jest.fn(),
get: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
});

describe('PipelineDraftController', () => {
let controller: PipelineDraftController;
let service: ReturnType<typeof mockPipelineDraftService>;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [PipelineDraftController],
providers: [
{
provide: PipelineDraftService,
useFactory: mockPipelineDraftService,
},
],
}).compile();

controller = module.get(PipelineDraftController);
service = module.get(PipelineDraftService);
});

describe('create', () => {
it('should create a pipeline draft', async () => {
const draft = pipelineDraftFactory.build({
rdiInstanceId: mockRdiInstanceId,
});
service.create.mockResolvedValueOnce(draft);

const result = await controller.create(mockRdiClientMetadata as any, {
data: draft.data,
});

expect(result).toEqual(draft);
expect(service.create).toHaveBeenCalledWith(
mockSessionMetadata,
mockRdiInstanceId,
{ data: draft.data },
);
});

it('should strip fields not declared on the create dto, such as an injected id', async () => {
const pipe = new ValidationPipe({ transform: true, whitelist: true });
const draft = pipelineDraftFactory.build();

const transformed = await pipe.transform(
{ data: draft.data, id: faker.string.uuid() },
{ type: 'body', metatype: CreatePipelineDraftDto },
);

expect(transformed).toEqual({ data: draft.data });
expect(transformed.id).toBeUndefined();
});
});

describe('list', () => {
it('should return list of drafts', async () => {
const drafts = pipelineDraftFactory.buildList(3, {
rdiInstanceId: mockRdiInstanceId,
});
service.list.mockResolvedValueOnce(drafts);

const result = await controller.list(mockRdiClientMetadata as any);

expect(result).toEqual(drafts);
expect(service.list).toHaveBeenCalledWith(
mockSessionMetadata,
mockRdiInstanceId,
);
});
});

describe('get', () => {
it('should return a single draft', async () => {
const draft = pipelineDraftFactory.build({
rdiInstanceId: mockRdiInstanceId,
});
service.get.mockResolvedValueOnce(draft);

const result = await controller.get(
mockRdiClientMetadata as any,
draft.id,
);

expect(result).toEqual(draft);
});

it('should throw NotFoundException when draft not found', async () => {
service.get.mockRejectedValueOnce(new NotFoundException());

await expect(
controller.get(mockRdiClientMetadata as any, faker.string.uuid()),
).rejects.toThrow(NotFoundException);
});
});

describe('update', () => {
it('should update a pipeline draft', async () => {
const draft = pipelineDraftFactory.build({
rdiInstanceId: mockRdiInstanceId,
});
const updatedData = { updated: true };
const updatedDraft = { ...draft, data: updatedData };
service.update.mockResolvedValueOnce(updatedDraft);

const result = await controller.update(
mockRdiClientMetadata as any,
draft.id,
{ data: updatedData },
);

expect(result).toEqual(updatedDraft);
});

it('should reject null data instead of treating it as an omitted field', async () => {
const pipe = new ValidationPipe({ transform: true, whitelist: true });

await expect(
pipe.transform(
{ data: null },
{ type: 'body', metatype: UpdatePipelineDraftDto },
),
).rejects.toThrow();
});
});

describe('delete', () => {
it('should delete a pipeline draft', async () => {
const draft = pipelineDraftFactory.build({
rdiInstanceId: mockRdiInstanceId,
});
service.delete.mockResolvedValueOnce(undefined);

await controller.delete(mockRdiClientMetadata as any, draft.id);

expect(service.delete).toHaveBeenCalledWith(
mockSessionMetadata,
mockRdiInstanceId,
draft.id,
);
});

it('should throw NotFoundException when draft not found', async () => {
service.delete.mockRejectedValueOnce(new NotFoundException());

await expect(
controller.delete(mockRdiClientMetadata as any, faker.string.uuid()),
).rejects.toThrow(NotFoundException);
});
});
});
Loading
Loading