Slave/blog #9

Merged
nobswebdev merged 14 commits from slave/blog into master 2026-09-16 20:15:08 +00:00
7 changed files with 295 additions and 0 deletions
Showing only changes of commit d38def2302 - Show all commits
+2
View File
@@ -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,
+12
View File
@@ -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);
}
}