Slave/blog #9
@@ -20,6 +20,7 @@ import {
|
||||
} from './config';
|
||||
import { validate } from './config/validate';
|
||||
import { AuthModule } from './modules/auth/AuthModule';
|
||||
import { BlogModule } from './modules/blog/BlogModule';
|
||||
import { EncryptionModule } from './modules/encryption/EncryptionModule';
|
||||
import { HealthCheckModule } from './modules/healthCheck/HealthCheckModule';
|
||||
import { MoneroWalletModule } from './modules/moneroWallet/MoneroWalletModule';
|
||||
@@ -85,6 +86,7 @@ import { Config } from './types/Config';
|
||||
HealthCheckModule,
|
||||
EncryptionModule,
|
||||
AuthModule,
|
||||
BlogModule,
|
||||
ProductsModule,
|
||||
ShopSettingsModule,
|
||||
SimplexModule,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BlogPostsController } from './controllers/BlogPostsController';
|
||||
import { BlogPost } from './entities/BlogPost';
|
||||
import { BlogPostsService } from './services/BlogPostsService';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([BlogPost])],
|
||||
controllers: [BlogPostsController],
|
||||
providers: [BlogPostsService]
|
||||
})
|
||||
export class BlogModule {}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { JwtGuard } from '../../../guards/JwtGuard';
|
||||
import { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto';
|
||||
import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto';
|
||||
import { BlogPostsService } from '../services/BlogPostsService';
|
||||
|
||||
@Controller('blog-posts')
|
||||
@UseGuards(JwtGuard)
|
||||
export class BlogPostsController {
|
||||
constructor(private readonly blogPostsService: BlogPostsService) {}
|
||||
|
||||
@Post('/')
|
||||
createDraft() {
|
||||
return this.blogPostsService.createDraft();
|
||||
}
|
||||
|
||||
@Get('/')
|
||||
findAll(@Query() query: ListBlogPostsQueryDto) {
|
||||
return this.blogPostsService.findAll(query);
|
||||
}
|
||||
|
||||
@Get('/:id')
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.blogPostsService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch('/:id')
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateBlogPostDto) {
|
||||
return this.blogPostsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete('/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
await this.blogPostsService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
export class ListBlogPostsQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { IsBoolean, IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
import { getAppConfig } from '../../../config';
|
||||
|
||||
const {
|
||||
validation: { blogPostTitleMaxLength }
|
||||
} = getAppConfig();
|
||||
|
||||
export class UpdateBlogPostDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@MaxLength(blogPostTitleMaxLength)
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
contentHtml: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsBoolean()
|
||||
isDraft: boolean;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import type { Repository } from 'typeorm';
|
||||
import { BlogPost } from '../entities/BlogPost';
|
||||
import { BlogPostsService } from './BlogPostsService';
|
||||
|
||||
describe('BlogPostsService', () => {
|
||||
let service: BlogPostsService;
|
||||
let blogPostRepo: {
|
||||
findAndCount: jest.Mock;
|
||||
findOne: jest.Mock;
|
||||
create: jest.Mock;
|
||||
insert: jest.Mock;
|
||||
save: jest.Mock;
|
||||
update: jest.Mock;
|
||||
exists: jest.Mock;
|
||||
delete: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
blogPostRepo = {
|
||||
findAndCount: jest.fn().mockResolvedValue([[], 0]),
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn(data => ({ id: 'post-1', ...data })),
|
||||
insert: jest.fn().mockResolvedValue(undefined),
|
||||
save: jest.fn(async (entity: BlogPost) => entity),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
exists: jest.fn().mockResolvedValue(false),
|
||||
delete: jest.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
|
||||
service = new BlogPostsService(blogPostRepo as unknown as Repository<BlogPost>);
|
||||
});
|
||||
|
||||
it('creates a draft post with empty title and content', async () => {
|
||||
blogPostRepo.findOne.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: '',
|
||||
contentHtml: '',
|
||||
isDraft: true
|
||||
});
|
||||
|
||||
const created = await service.createDraft();
|
||||
|
||||
expect(blogPostRepo.create).toHaveBeenCalledWith({
|
||||
title: '',
|
||||
contentHtml: '',
|
||||
isDraft: true
|
||||
});
|
||||
expect(blogPostRepo.insert).toHaveBeenCalled();
|
||||
expect(created).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'post-1',
|
||||
title: '',
|
||||
contentHtml: '',
|
||||
isDraft: true
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('lists posts ordered by createdAt descending', async () => {
|
||||
await service.findAll({ page: 2, limit: 10 });
|
||||
|
||||
expect(blogPostRepo.findAndCount).toHaveBeenCalledWith({
|
||||
order: { createdAt: 'DESC' },
|
||||
skip: 10,
|
||||
take: 10
|
||||
});
|
||||
});
|
||||
|
||||
it('trims title on update', async () => {
|
||||
blogPostRepo.exists.mockResolvedValueOnce(true);
|
||||
blogPostRepo.findOne.mockResolvedValueOnce({
|
||||
id: 'post-1',
|
||||
title: 'Hello',
|
||||
contentHtml: '<p>Body</p>',
|
||||
isDraft: false
|
||||
});
|
||||
|
||||
await service.update('post-1', {
|
||||
title: ' Hello ',
|
||||
contentHtml: '<p>Body</p>',
|
||||
isDraft: false
|
||||
});
|
||||
|
||||
expect(blogPostRepo.update).toHaveBeenCalledWith('post-1', {
|
||||
title: 'Hello',
|
||||
contentHtml: '<p>Body</p>',
|
||||
isDraft: false
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when loading a missing post', async () => {
|
||||
await expect(service.findOne('missing-id')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('throws when updating a missing post', async () => {
|
||||
blogPostRepo.exists.mockResolvedValueOnce(false);
|
||||
|
||||
await expect(
|
||||
service.update('missing-id', {
|
||||
title: 'Hello',
|
||||
contentHtml: '',
|
||||
isDraft: true
|
||||
})
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('deletes an existing post', async () => {
|
||||
blogPostRepo.exists.mockResolvedValue(true);
|
||||
|
||||
await service.remove('post-1');
|
||||
|
||||
expect(blogPostRepo.delete).toHaveBeenCalledWith('post-1');
|
||||
});
|
||||
|
||||
it('throws when deleting a missing post', async () => {
|
||||
await expect(service.remove('missing-id')).rejects.toThrow(NotFoundException);
|
||||
expect(blogPostRepo.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import type { PaginatedResponse } from '../../../types/PaginatedResponse';
|
||||
import type { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto';
|
||||
import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto';
|
||||
import { BlogPost } from '../entities/BlogPost';
|
||||
|
||||
@Injectable()
|
||||
export class BlogPostsService {
|
||||
constructor(
|
||||
@InjectRepository(BlogPost)
|
||||
private readonly blogPostRepo: Repository<BlogPost>
|
||||
) {}
|
||||
|
||||
async findAll({ page = 1, limit = 20 }: ListBlogPostsQueryDto): Promise<PaginatedResponse<BlogPost>> {
|
||||
const [items, total] = await this.blogPostRepo.findAndCount({
|
||||
order: { createdAt: 'DESC' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit
|
||||
});
|
||||
|
||||
return { items, total, page, limit };
|
||||
}
|
||||
|
||||
async createDraft(): Promise<BlogPost> {
|
||||
const entity = this.blogPostRepo.create({
|
||||
title: '',
|
||||
contentHtml: '',
|
||||
isDraft: true
|
||||
});
|
||||
|
||||
await this.blogPostRepo.insert(entity);
|
||||
|
||||
return this.findOne(entity.id);
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<BlogPost> {
|
||||
const entity = await this.blogPostRepo.findOne({ where: { id } });
|
||||
|
||||
if (!entity) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
async update(id: string, { title, contentHtml, isDraft }: UpdateBlogPostDto): Promise<BlogPost> {
|
||||
const exists = await this.blogPostRepo.exists({ where: { id } });
|
||||
|
||||
if (!exists) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await this.blogPostRepo.update(id, {
|
||||
title: title.trim(),
|
||||
contentHtml,
|
||||
isDraft
|
||||
});
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const exists = await this.blogPostRepo.exists({ where: { id } });
|
||||
|
||||
if (!exists) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await this.blogPostRepo.delete(id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user