diff --git a/src/business/notice/migrations/create-notices-table.sql b/src/business/notice/migrations/create-notices-table.sql new file mode 100644 index 0000000..a721a8d --- /dev/null +++ b/src/business/notice/migrations/create-notices-table.sql @@ -0,0 +1,21 @@ +-- 创建通知表 +CREATE TABLE IF NOT EXISTS `notices` ( + `id` int NOT NULL AUTO_INCREMENT, + `title` varchar(255) NOT NULL COMMENT '通知标题', + `content` text NOT NULL COMMENT '通知内容', + `type` enum('system','user','broadcast') NOT NULL DEFAULT 'system' COMMENT '通知类型', + `status` enum('pending','sent','read','failed') NOT NULL DEFAULT 'pending' COMMENT '通知状态', + `userId` int DEFAULT NULL COMMENT '接收者用户ID,NULL表示广播', + `senderId` int DEFAULT NULL COMMENT '发送者用户ID', + `scheduledAt` datetime DEFAULT NULL COMMENT '计划发送时间', + `sentAt` datetime DEFAULT NULL COMMENT '实际发送时间', + `readAt` datetime DEFAULT NULL COMMENT '阅读时间', + `metadata` json DEFAULT NULL COMMENT '额外数据', + `createdAt` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间', + `updatedAt` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_notices_user_id` (`userId`), + KEY `idx_notices_status` (`status`), + KEY `idx_notices_scheduled_at` (`scheduledAt`), + KEY `idx_notices_created_at` (`createdAt`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='通知表'; \ No newline at end of file diff --git a/src/business/notice/notice.service.spec.ts b/src/business/notice/notice.service.spec.ts new file mode 100644 index 0000000..fc2cba5 --- /dev/null +++ b/src/business/notice/notice.service.spec.ts @@ -0,0 +1,202 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NoticeService } from './notice.service'; +import { NoticeGateway } from './notice.gateway'; +import { Notice, NoticeStatus, NoticeType } from './notice.entity'; + +describe('NoticeService', () => { + let service: NoticeService; + let repository: Repository; + let gateway: NoticeGateway; + + const mockRepository = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + count: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockGateway = { + sendToUser: jest.fn(), + broadcast: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NoticeService, + { + provide: getRepositoryToken(Notice), + useValue: mockRepository, + }, + { + provide: NoticeGateway, + useValue: mockGateway, + }, + ], + }).compile(); + + service = module.get(NoticeService); + repository = module.get>(getRepositoryToken(Notice)); + gateway = module.get(NoticeGateway); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('should create and send notice immediately when no scheduledAt', async () => { + const createDto = { + title: 'Test Notice', + content: 'Test Content', + userId: 1, + }; + + const mockNotice = { + id: 1, + ...createDto, + status: NoticeStatus.PENDING, + type: NoticeType.SYSTEM, + scheduledAt: null, + }; + + mockRepository.create.mockReturnValue(mockNotice); + mockRepository.save.mockResolvedValueOnce(mockNotice); + mockRepository.save.mockResolvedValueOnce({ + ...mockNotice, + status: NoticeStatus.SENT, + sentAt: new Date(), + }); + + const result = await service.create(createDto); + + expect(mockRepository.create).toHaveBeenCalledWith({ + ...createDto, + scheduledAt: null, + }); + expect(mockRepository.save).toHaveBeenCalledTimes(2); + expect(mockGateway.sendToUser).toHaveBeenCalledWith(1, { + type: 'notice', + data: mockNotice, + }); + }); + + it('should create scheduled notice without sending immediately', async () => { + const scheduledAt = new Date(Date.now() + 3600000); // 1 hour later + const createDto = { + title: 'Scheduled Notice', + content: 'Scheduled Content', + scheduledAt: scheduledAt.toISOString(), + }; + + const mockNotice = { + id: 1, + ...createDto, + scheduledAt, + status: NoticeStatus.PENDING, + }; + + mockRepository.create.mockReturnValue(mockNotice); + mockRepository.save.mockResolvedValue(mockNotice); + + const result = await service.create(createDto); + + expect(mockGateway.sendToUser).not.toHaveBeenCalled(); + expect(mockGateway.broadcast).not.toHaveBeenCalled(); + }); + }); + + describe('sendSystemNotice', () => { + it('should create and send system notice', async () => { + const mockNotice = { + id: 1, + title: 'System Notice', + content: 'System Content', + type: NoticeType.SYSTEM, + userId: 1, + status: NoticeStatus.PENDING, + }; + + mockRepository.create.mockReturnValue(mockNotice); + mockRepository.save.mockResolvedValueOnce(mockNotice); + mockRepository.save.mockResolvedValueOnce({ + ...mockNotice, + status: NoticeStatus.SENT, + }); + + const result = await service.sendSystemNotice('System Notice', 'System Content', 1); + + expect(result.type).toBe(NoticeType.SYSTEM); + expect(mockGateway.sendToUser).toHaveBeenCalled(); + }); + }); + + describe('sendBroadcast', () => { + it('should create and send broadcast notice', async () => { + const mockNotice = { + id: 1, + title: 'Broadcast Notice', + content: 'Broadcast Content', + type: NoticeType.BROADCAST, + userId: null, + status: NoticeStatus.PENDING, + }; + + mockRepository.create.mockReturnValue(mockNotice); + mockRepository.save.mockResolvedValueOnce(mockNotice); + mockRepository.save.mockResolvedValueOnce({ + ...mockNotice, + status: NoticeStatus.SENT, + }); + + const result = await service.sendBroadcast('Broadcast Notice', 'Broadcast Content'); + + expect(result.type).toBe(NoticeType.BROADCAST); + expect(mockGateway.broadcast).toHaveBeenCalled(); + }); + }); + + describe('markAsRead', () => { + it('should mark notice as read', async () => { + const mockNotice = { + id: 1, + userId: 1, + status: NoticeStatus.SENT, + }; + + const updatedNotice = { + ...mockNotice, + status: NoticeStatus.READ, + readAt: new Date(), + }; + + mockRepository.findOne.mockResolvedValue(mockNotice); + mockRepository.save.mockResolvedValue(updatedNotice); + + const result = await service.markAsRead(1, 1); + + expect(result.status).toBe(NoticeStatus.READ); + expect(result.readAt).toBeDefined(); + }); + }); + + describe('getUserUnreadCount', () => { + it('should return unread count for user', async () => { + mockRepository.count.mockResolvedValue(5); + + const count = await service.getUserUnreadCount(1); + + expect(count).toBe(5); + expect(mockRepository.count).toHaveBeenCalledWith({ + where: [ + { userId: 1, status: NoticeStatus.SENT }, + { userId: null, status: NoticeStatus.SENT }, + ], + }); + }); + }); +}); \ No newline at end of file