Slave/blog #9
@@ -20,6 +20,7 @@ import {
|
|||||||
} from './config';
|
} from './config';
|
||||||
import { validate } from './config/validate';
|
import { validate } from './config/validate';
|
||||||
import { AuthModule } from './modules/auth/AuthModule';
|
import { AuthModule } from './modules/auth/AuthModule';
|
||||||
|
import { BlogModule } from './modules/blog/BlogModule';
|
||||||
import { EncryptionModule } from './modules/encryption/EncryptionModule';
|
import { EncryptionModule } from './modules/encryption/EncryptionModule';
|
||||||
import { HealthCheckModule } from './modules/healthCheck/HealthCheckModule';
|
import { HealthCheckModule } from './modules/healthCheck/HealthCheckModule';
|
||||||
import { MoneroWalletModule } from './modules/moneroWallet/MoneroWalletModule';
|
import { MoneroWalletModule } from './modules/moneroWallet/MoneroWalletModule';
|
||||||
@@ -85,6 +86,7 @@ import { Config } from './types/Config';
|
|||||||
HealthCheckModule,
|
HealthCheckModule,
|
||||||
EncryptionModule,
|
EncryptionModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
BlogModule,
|
||||||
ProductsModule,
|
ProductsModule,
|
||||||
ShopSettingsModule,
|
ShopSettingsModule,
|
||||||
SimplexModule,
|
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