diff --git a/backend/src/posts/dto/post.dto.spec.ts b/backend/src/posts/dto/post.dto.spec.ts new file mode 100644 index 00000000..787d8766 --- /dev/null +++ b/backend/src/posts/dto/post.dto.spec.ts @@ -0,0 +1,196 @@ +import { validate } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; +import { CreatePostDto, UpdatePostDto } from './post.dto'; + +async function validateDto(cls: new () => T, plain: object) { + const dto = plainToInstance(cls, plain); + return validate(dto); +} + +describe('CreatePostDto', () => { + const valid = { title: 'Hello', content: 'World' }; + + it('accepts a valid payload', async () => { + const errors = await validateDto(CreatePostDto, valid); + expect(errors).toHaveLength(0); + }); + + describe('title', () => { + it('rejects missing title', async () => { + const errors = await validateDto(CreatePostDto, { content: 'Body' }); + expect(errors.some((e) => e.property === 'title')).toBe(true); + }); + + it('rejects empty title', async () => { + const errors = await validateDto(CreatePostDto, { ...valid, title: '' }); + expect(errors.some((e) => e.property === 'title')).toBe(true); + }); + + it('rejects title exceeding 500 characters', async () => { + const errors = await validateDto(CreatePostDto, { + ...valid, + title: 'a'.repeat(501), + }); + expect(errors.some((e) => e.property === 'title')).toBe(true); + }); + + it('rejects non-string title', async () => { + const errors = await validateDto(CreatePostDto, { ...valid, title: 123 }); + expect(errors.some((e) => e.property === 'title')).toBe(true); + }); + + it('accepts title exactly 500 characters', async () => { + const errors = await validateDto(CreatePostDto, { + ...valid, + title: 'a'.repeat(500), + }); + expect(errors).toHaveLength(0); + }); + }); + + describe('content', () => { + it('rejects missing content', async () => { + const errors = await validateDto(CreatePostDto, { title: 'T' }); + expect(errors.some((e) => e.property === 'content')).toBe(true); + }); + + it('rejects empty content', async () => { + const errors = await validateDto(CreatePostDto, { ...valid, content: '' }); + expect(errors.some((e) => e.property === 'content')).toBe(true); + }); + + it('rejects content exceeding 10000 characters', async () => { + const errors = await validateDto(CreatePostDto, { + ...valid, + content: 'a'.repeat(10001), + }); + expect(errors.some((e) => e.property === 'content')).toBe(true); + }); + + it('accepts content exactly 10000 characters', async () => { + const errors = await validateDto(CreatePostDto, { + ...valid, + content: 'a'.repeat(10000), + }); + expect(errors).toHaveLength(0); + }); + }); + + describe('isPublished', () => { + it('accepts boolean true', async () => { + const errors = await validateDto(CreatePostDto, { + ...valid, + isPublished: true, + }); + expect(errors).toHaveLength(0); + }); + + it('accepts boolean false', async () => { + const errors = await validateDto(CreatePostDto, { + ...valid, + isPublished: false, + }); + expect(errors).toHaveLength(0); + }); + + it('rejects non-boolean value', async () => { + const errors = await validateDto(CreatePostDto, { + ...valid, + isPublished: 'yes', + }); + expect(errors.some((e) => e.property === 'isPublished')).toBe(true); + }); + + it('is optional (absent is valid)', async () => { + const errors = await validateDto(CreatePostDto, valid); + expect(errors).toHaveLength(0); + }); + }); + + describe('isPremium', () => { + it('accepts boolean true', async () => { + const errors = await validateDto(CreatePostDto, { + ...valid, + isPremium: true, + }); + expect(errors).toHaveLength(0); + }); + + it('rejects non-boolean value', async () => { + const errors = await validateDto(CreatePostDto, { + ...valid, + isPremium: 1, + }); + expect(errors.some((e) => e.property === 'isPremium')).toBe(true); + }); + + it('is optional (absent is valid)', async () => { + const errors = await validateDto(CreatePostDto, valid); + expect(errors).toHaveLength(0); + }); + }); +}); + +describe('UpdatePostDto', () => { + it('accepts an empty object (all fields optional)', async () => { + const errors = await validateDto(UpdatePostDto, {}); + expect(errors).toHaveLength(0); + }); + + it('accepts a fully populated valid payload', async () => { + const errors = await validateDto(UpdatePostDto, { + title: 'Updated', + content: 'New body', + isPublished: true, + isPremium: false, + }); + expect(errors).toHaveLength(0); + }); + + describe('title', () => { + it('rejects empty string', async () => { + const errors = await validateDto(UpdatePostDto, { title: '' }); + expect(errors.some((e) => e.property === 'title')).toBe(true); + }); + + it('rejects title exceeding 500 characters', async () => { + const errors = await validateDto(UpdatePostDto, { + title: 'a'.repeat(501), + }); + expect(errors.some((e) => e.property === 'title')).toBe(true); + }); + + it('rejects non-string title', async () => { + const errors = await validateDto(UpdatePostDto, { title: 42 }); + expect(errors.some((e) => e.property === 'title')).toBe(true); + }); + }); + + describe('content', () => { + it('rejects empty string', async () => { + const errors = await validateDto(UpdatePostDto, { content: '' }); + expect(errors.some((e) => e.property === 'content')).toBe(true); + }); + + it('rejects content exceeding 10000 characters', async () => { + const errors = await validateDto(UpdatePostDto, { + content: 'a'.repeat(10001), + }); + expect(errors.some((e) => e.property === 'content')).toBe(true); + }); + }); + + describe('isPublished', () => { + it('rejects non-boolean value', async () => { + const errors = await validateDto(UpdatePostDto, { isPublished: 'true' }); + expect(errors.some((e) => e.property === 'isPublished')).toBe(true); + }); + }); + + describe('isPremium', () => { + it('rejects non-boolean value', async () => { + const errors = await validateDto(UpdatePostDto, { isPremium: 0 }); + expect(errors.some((e) => e.property === 'isPremium')).toBe(true); + }); + }); +}); diff --git a/backend/src/posts/dto/post.dto.ts b/backend/src/posts/dto/post.dto.ts index 7d10b723..a5bbe2ad 100644 --- a/backend/src/posts/dto/post.dto.ts +++ b/backend/src/posts/dto/post.dto.ts @@ -1,18 +1,20 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Expose } from 'class-transformer'; +import { + IsBoolean, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, + MinLength, +} from 'class-validator'; export class PostDto { - @ApiProperty({ - example: 'post-123', - description: 'Unique post identifier', - }) + @ApiProperty({ example: 'post-123', description: 'Unique post identifier' }) @Expose() id: string; - @ApiProperty({ - example: 'My First Post', - description: 'Post title', - }) + @ApiProperty({ example: 'My First Post', description: 'Post title' }) @Expose() title: string; @@ -23,17 +25,11 @@ export class PostDto { @Expose() content: string; - @ApiProperty({ - example: 'author-456', - description: 'Author user ID', - }) + @ApiProperty({ example: 'author-456', description: 'Author user ID' }) @Expose() authorId: string; - @ApiProperty({ - example: true, - description: 'Whether post is published', - }) + @ApiProperty({ example: true, description: 'Whether post is published' }) @Expose() isPublished: boolean; @@ -44,10 +40,7 @@ export class PostDto { @Expose() isPremium: boolean; - @ApiProperty({ - example: 5, - description: 'Number of likes', - }) + @ApiProperty({ example: 5, description: 'Number of likes' }) @Expose() likesCount: number; @@ -81,6 +74,10 @@ export class CreatePostDto { minLength: 1, maxLength: 500, }) + @IsNotEmpty() + @IsString() + @MinLength(1) + @MaxLength(500) title: string; @ApiProperty({ @@ -89,18 +86,26 @@ export class CreatePostDto { minLength: 1, maxLength: 10000, }) + @IsNotEmpty() + @IsString() + @MinLength(1) + @MaxLength(10000) content: string; @ApiPropertyOptional({ example: true, description: 'Whether to publish post immediately (default: false)', }) + @IsOptional() + @IsBoolean() isPublished?: boolean; @ApiPropertyOptional({ example: false, description: 'Whether this is a premium post (default: false)', }) + @IsOptional() + @IsBoolean() isPremium?: boolean; } @@ -111,6 +116,10 @@ export class UpdatePostDto { minLength: 1, maxLength: 500, }) + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(500) title?: string; @ApiPropertyOptional({ @@ -119,17 +128,22 @@ export class UpdatePostDto { minLength: 1, maxLength: 10000, }) + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(10000) content?: string; - @ApiPropertyOptional({ - example: true, - description: 'Whether to publish post', - }) + @ApiPropertyOptional({ example: true, description: 'Whether to publish post' }) + @IsOptional() + @IsBoolean() isPublished?: boolean; @ApiPropertyOptional({ example: false, description: 'Whether this is a premium post', }) + @IsOptional() + @IsBoolean() isPremium?: boolean; } diff --git a/backend/test/posts.e2e-spec.ts b/backend/test/posts.e2e-spec.ts new file mode 100644 index 00000000..31ee8de6 --- /dev/null +++ b/backend/test/posts.e2e-spec.ts @@ -0,0 +1,127 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; + +describe('Posts Module (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' }); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + // ── GET /v1/posts (primary endpoint) ──────────────────────────────────────── + + describe('GET /v1/posts', () => { + it('returns 200 with paginated response shape', async () => { + const res = await request(app.getHttpServer()).get('/v1/posts').expect(200); + + expect(res.body).toHaveProperty('data'); + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body).toHaveProperty('limit'); + expect(res.body).toHaveProperty('hasMore'); + }); + + it('supports page and limit query params', async () => { + const res = await request(app.getHttpServer()) + .get('/v1/posts') + .query({ page: 1, limit: 5 }) + .expect(200); + + expect(res.body.limit).toBe(5); + }); + + it('returns 400 for limit exceeding 100', async () => { + await request(app.getHttpServer()) + .get('/v1/posts') + .query({ limit: 101 }) + .expect(400); + }); + + it('returns 400 for page less than 1', async () => { + await request(app.getHttpServer()) + .get('/v1/posts') + .query({ page: 0 }) + .expect(400); + }); + + it('returns empty data array when no posts exist', async () => { + const res = await request(app.getHttpServer()) + .get('/v1/posts') + .expect(200); + + expect(Array.isArray(res.body.data)).toBe(true); + }); + }); + + // ── POST /v1/posts ──────────────────────────────────────────────────────────── + + describe('POST /v1/posts', () => { + it('returns 400 for missing title', async () => { + await request(app.getHttpServer()) + .post('/v1/posts') + .send({ content: 'Some content' }) + .expect(400); + }); + + it('returns 400 for missing content', async () => { + await request(app.getHttpServer()) + .post('/v1/posts') + .send({ title: 'A title' }) + .expect(400); + }); + + it('returns 400 for empty title', async () => { + await request(app.getHttpServer()) + .post('/v1/posts') + .send({ title: '', content: 'Body' }) + .expect(400); + }); + + it('returns 400 for title exceeding 500 characters', async () => { + await request(app.getHttpServer()) + .post('/v1/posts') + .send({ title: 'a'.repeat(501), content: 'Body' }) + .expect(400); + }); + + it('returns 400 for non-boolean isPublished', async () => { + await request(app.getHttpServer()) + .post('/v1/posts') + .send({ title: 'T', content: 'C', isPublished: 'yes' }) + .expect(400); + }); + }); + + // ── GET /v1/posts/author/:authorId ──────────────────────────────────────────── + + describe('GET /v1/posts/author/:authorId', () => { + it('returns 200 with paginated response for any authorId', async () => { + const res = await request(app.getHttpServer()) + .get('/v1/posts/author/nonexistent-author') + .expect(200); + + expect(Array.isArray(res.body.data)).toBe(true); + }); + + it('supports limit query param', async () => { + const res = await request(app.getHttpServer()) + .get('/v1/posts/author/some-author') + .query({ limit: 10 }) + .expect(200); + + expect(res.body.limit).toBe(10); + }); + }); +});