feat: 支持课后评价与成长档案
完成后即可评价并订阅提醒,馆主可记录体测、笔记与成长照片,首页展示匿名星级均分。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
69
packages/server/src/booking/__tests__/review.service.spec.ts
Normal file
69
packages/server/src/booking/__tests__/review.service.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { ReviewService, summarizeReviews } from '../review.service'
|
||||
import { PrismaService } from '../../prisma/prisma.service'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { CreateReviewDto } from '../dto/create-review.dto'
|
||||
import { validate } from 'class-validator'
|
||||
import 'reflect-metadata'
|
||||
import { Reflector } from '@nestjs/core'
|
||||
import { ExecutionContext } from '@nestjs/common'
|
||||
import { GUARDS_METADATA } from '@nestjs/common/constants'
|
||||
import { ReviewController, PublicReviewController } from '../review.controller'
|
||||
import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
|
||||
import { RolesGuard } from '../../auth/roles.guard'
|
||||
describe('Class reviews', () => {
|
||||
const db = { booking: { findFirst: jest.fn() }, bookingReview: { create: jest.fn(), findMany: jest.fn(), count: jest.fn() } }
|
||||
let service: ReviewService
|
||||
beforeEach(() => { jest.resetAllMocks(); service = new ReviewService(db as unknown as PrismaService) })
|
||||
const dto = { rating: 5, tags: ['氛围好'], comment: '很有收获' }
|
||||
it('does not expose another member booking or review', async () => {
|
||||
db.booking.findFirst.mockResolvedValue(null)
|
||||
await expect(service.get('other', 'booking')).rejects.toThrow('预约不存在')
|
||||
expect(db.booking.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'booking', userId: 'other' } }))
|
||||
await expect(service.create('other', 'booking', dto)).rejects.toThrow('预约不存在')
|
||||
expect(db.bookingReview.create).not.toHaveBeenCalled()
|
||||
})
|
||||
it.each(['CONFIRMED', 'NO_SHOW', 'CANCELLED', 'PENDING_CONFIRMATION'])('rejects %s booking', async status => {
|
||||
db.booking.findFirst.mockResolvedValue({ status })
|
||||
await expect(service.create('member', 'booking', dto)).rejects.toThrow('完成课程后才能评价')
|
||||
})
|
||||
it('allows older completed lessons, with database uniqueness protecting double submits', async () => {
|
||||
db.booking.findFirst.mockResolvedValue({ status: 'COMPLETED', completedAt: new Date('2020-01-01') })
|
||||
db.bookingReview.create.mockResolvedValueOnce({ id: 'review' }).mockRejectedValueOnce(new Prisma.PrismaClientKnownRequestError('duplicate', { code: 'P2002', clientVersion: '5' }))
|
||||
await expect(service.create('member', 'booking', dto)).resolves.toEqual({ id: 'review' })
|
||||
await expect(service.create('member', 'booking', dto)).rejects.toThrow('已经评价')
|
||||
})
|
||||
it.each([{ rating: 0 }, { rating: 6 }, { rating: 2.5 }, { tags: ['伪造标签'] }, { tags: ['氛围好', '氛围好'] }, { tags: ['动作到位','氛围好','强度合适','讲解清晰'] }, { recommendation: 11 }, { recommendation: -1 }, { comment: '长'.repeat(201) }])('rejects invalid payload %j', async patch => {
|
||||
expect((await validate(Object.assign(new CreateReviewDto(), dto, patch))).length).toBeGreaterThan(0)
|
||||
})
|
||||
it('accepts optional recommendation including zero', async () => {
|
||||
expect(await validate(Object.assign(new CreateReviewDto(), dto, { recommendation: 0 }))).toHaveLength(0)
|
||||
})
|
||||
it('keeps star average separate from NPS and handles zero samples', () => {
|
||||
expect(summarizeReviews([])).toEqual({ count: 0, average: null, nps: null, npsCount: 0 })
|
||||
expect(summarizeReviews([{ rating: 5, recommendation: 0 }, { rating: 1, recommendation: 9 }, { rating: 4, recommendation: 7 }, { rating: 4, recommendation: null }])).toEqual({ count: 4, average: 3.5, nps: 0, npsCount: 3 })
|
||||
})
|
||||
it('groups the six Chinese calendar months across year and UTC boundaries', async () => {
|
||||
db.bookingReview.findMany.mockResolvedValue([{ rating: 5, recommendation: 10, createdAt: new Date('2025-12-31T16:00:00Z') }, { rating: 1, recommendation: 0, createdAt: new Date('2025-12-31T15:59:59Z') }])
|
||||
const result = await service.trend('2026-01')
|
||||
expect(result.map(r => r.month)).toEqual(['2025-08','2025-09','2025-10','2025-11','2025-12','2026-01'])
|
||||
expect(result[4].average).toBe(1); expect(result[5].average).toBe(5)
|
||||
expect(db.bookingReview.findMany.mock.calls[0][0].where.createdAt).toEqual({ gte: new Date('2025-07-31T16:00:00Z'), lt: new Date('2026-01-31T16:00:00Z') })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Review authorization', () => {
|
||||
it('requires authentication for member and admin review endpoints', () => {
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, ReviewController)).toContain(JwtAuthGuard)
|
||||
})
|
||||
it('keeps the public summary unauthenticated', () => {
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, PublicReviewController) || []).not.toContain(JwtAuthGuard)
|
||||
})
|
||||
it.each(['list', 'trend'] as const)('restricts %s to admins', method => {
|
||||
const handler = ReviewController.prototype[method]
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, handler)).toContain(RolesGuard)
|
||||
const guard = new RolesGuard(new Reflector())
|
||||
const context = (role: string) => ({ getHandler: () => handler, getClass: () => ReviewController, switchToHttp: () => ({ getRequest: () => ({ user: { role } }) }) }) as unknown as ExecutionContext
|
||||
expect(guard.canActivate(context('MEMBER'))).toBe(false)
|
||||
expect(guard.canActivate(context('ADMIN'))).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user