feat(server): add auth, user, and studio modules
Auth: WeChat login, JWT, roles guard (24 tests passing) User: profile CRUD, training stats with month/total calculations Studio: config management with auto-default creation
This commit is contained in:
275
packages/server/src/user/__tests__/user.service.spec.ts
Normal file
275
packages/server/src/user/__tests__/user.service.spec.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing'
|
||||
import { NotFoundException } from '@nestjs/common'
|
||||
import { UserService } from '../user.service'
|
||||
import { PrismaService } from '../../prisma/prisma.service'
|
||||
import { MembershipStatus, BookingStatus, UserRole } from '@mp-pilates/shared'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const makeUser = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'user-1',
|
||||
openid: 'openid-1',
|
||||
unionid: null,
|
||||
phone: '13800000000',
|
||||
nickname: 'Alice',
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
role: UserRole.MEMBER,
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2024-01-01T00:00:00Z'),
|
||||
_count: { memberships: 2 },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const makeBooking = (
|
||||
date: Date,
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
status: BookingStatus = BookingStatus.COMPLETED,
|
||||
) => ({
|
||||
id: `booking-${Math.random()}`,
|
||||
userId: 'user-1',
|
||||
timeSlotId: `slot-${Math.random()}`,
|
||||
membershipId: `membership-${Math.random()}`,
|
||||
status,
|
||||
cancelledAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
timeSlot: { date, startTime, endTime },
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock PrismaService
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockPrisma = {
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
booking: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('UserService', () => {
|
||||
let service: UserService
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
UserService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
],
|
||||
}).compile()
|
||||
|
||||
service = module.get<UserService>(UserService)
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// getProfile
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('getProfile', () => {
|
||||
it('returns a UserProfileResponse with activeMembershipCount', async () => {
|
||||
const user = makeUser({ _count: { memberships: 3 } })
|
||||
mockPrisma.user.findUnique.mockResolvedValue(user)
|
||||
|
||||
const result = await service.getProfile('user-1')
|
||||
|
||||
expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'user-1' },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
memberships: { where: { status: MembershipStatus.ACTIVE } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'user-1',
|
||||
phone: '13800000000',
|
||||
nickname: 'Alice',
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
role: UserRole.MEMBER,
|
||||
activeMembershipCount: 3,
|
||||
createdAt: new Date('2024-01-01T00:00:00Z').toISOString(),
|
||||
})
|
||||
})
|
||||
|
||||
it('throws NotFoundException when user does not exist', async () => {
|
||||
mockPrisma.user.findUnique.mockResolvedValue(null)
|
||||
|
||||
await expect(service.getProfile('unknown')).rejects.toThrow(NotFoundException)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// updateProfile
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('updateProfile', () => {
|
||||
it('updates nickname and avatarUrl, returns new UserProfileResponse', async () => {
|
||||
const updated = makeUser({
|
||||
nickname: 'Bob',
|
||||
avatarUrl: 'https://example.com/new.png',
|
||||
_count: { memberships: 1 },
|
||||
})
|
||||
mockPrisma.user.update.mockResolvedValue(updated)
|
||||
|
||||
const result = await service.updateProfile('user-1', {
|
||||
nickname: 'Bob',
|
||||
avatarUrl: 'https://example.com/new.png',
|
||||
})
|
||||
|
||||
expect(mockPrisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: 'user-1' },
|
||||
data: { nickname: 'Bob', avatarUrl: 'https://example.com/new.png' },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
memberships: { where: { status: MembershipStatus.ACTIVE } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.nickname).toBe('Bob')
|
||||
expect(result.avatarUrl).toBe('https://example.com/new.png')
|
||||
expect(result.activeMembershipCount).toBe(1)
|
||||
})
|
||||
|
||||
it('only includes provided fields in the update payload', async () => {
|
||||
const updated = makeUser({ nickname: 'Charlie', _count: { memberships: 0 } })
|
||||
mockPrisma.user.update.mockResolvedValue(updated)
|
||||
|
||||
await service.updateProfile('user-1', { nickname: 'Charlie' })
|
||||
|
||||
const callArgs = mockPrisma.user.update.mock.calls[0][0]
|
||||
expect(callArgs.data).toEqual({ nickname: 'Charlie' })
|
||||
expect(callArgs.data.avatarUrl).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns an immutable snapshot — the original dto is not mutated', async () => {
|
||||
const updated = makeUser({ _count: { memberships: 0 } })
|
||||
mockPrisma.user.update.mockResolvedValue(updated)
|
||||
|
||||
const dto = { nickname: 'Dave' }
|
||||
const originalDto = { ...dto }
|
||||
|
||||
await service.updateProfile('user-1', dto)
|
||||
|
||||
expect(dto).toEqual(originalDto)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// getStats
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('getStats', () => {
|
||||
/**
|
||||
* Build a date in the *current* month so the month-filter logic works
|
||||
* regardless of when the test is run.
|
||||
*/
|
||||
const thisYear = new Date().getFullYear()
|
||||
const thisMonth = new Date().getMonth()
|
||||
|
||||
const dateInMonth = (day: number) => new Date(thisYear, thisMonth, day)
|
||||
const dateLastMonth = new Date(thisYear, thisMonth - 1, 15)
|
||||
|
||||
it('returns zeroed stats when there are no completed bookings', async () => {
|
||||
mockPrisma.booking.findMany.mockResolvedValue([])
|
||||
|
||||
const result = await service.getStats('user-1')
|
||||
|
||||
expect(result).toEqual({
|
||||
totalBookings: 0,
|
||||
totalDays: 0,
|
||||
monthBookings: 0,
|
||||
monthDays: 0,
|
||||
monthHours: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('counts all completed bookings for totalBookings', async () => {
|
||||
mockPrisma.booking.findMany.mockResolvedValue([
|
||||
makeBooking(dateInMonth(1), '09:00', '10:00'),
|
||||
makeBooking(dateLastMonth, '09:00', '10:00'),
|
||||
])
|
||||
|
||||
const result = await service.getStats('user-1')
|
||||
|
||||
expect(result.totalBookings).toBe(2)
|
||||
})
|
||||
|
||||
it('counts distinct dates for totalDays', async () => {
|
||||
mockPrisma.booking.findMany.mockResolvedValue([
|
||||
makeBooking(dateInMonth(1), '09:00', '10:00'),
|
||||
makeBooking(dateInMonth(1), '11:00', '12:00'), // same day → still 1 distinct day
|
||||
makeBooking(dateLastMonth, '09:00', '10:00'),
|
||||
])
|
||||
|
||||
const result = await service.getStats('user-1')
|
||||
|
||||
expect(result.totalDays).toBe(2) // day-in-month(1) + last-month-day
|
||||
})
|
||||
|
||||
it('only counts this-month bookings in monthBookings', async () => {
|
||||
mockPrisma.booking.findMany.mockResolvedValue([
|
||||
makeBooking(dateInMonth(5), '09:00', '10:00'),
|
||||
makeBooking(dateInMonth(10), '09:00', '10:00'),
|
||||
makeBooking(dateLastMonth, '09:00', '10:00'), // excluded
|
||||
])
|
||||
|
||||
const result = await service.getStats('user-1')
|
||||
|
||||
expect(result.monthBookings).toBe(2)
|
||||
})
|
||||
|
||||
it('counts distinct this-month dates for monthDays', async () => {
|
||||
mockPrisma.booking.findMany.mockResolvedValue([
|
||||
makeBooking(dateInMonth(3), '09:00', '10:00'),
|
||||
makeBooking(dateInMonth(3), '11:00', '12:00'), // same day
|
||||
makeBooking(dateInMonth(7), '09:00', '10:00'),
|
||||
makeBooking(dateLastMonth, '09:00', '10:00'), // excluded
|
||||
])
|
||||
|
||||
const result = await service.getStats('user-1')
|
||||
|
||||
expect(result.monthDays).toBe(2)
|
||||
})
|
||||
|
||||
it('sums hours for monthHours from startTime/endTime', async () => {
|
||||
mockPrisma.booking.findMany.mockResolvedValue([
|
||||
makeBooking(dateInMonth(1), '09:00', '10:00'), // 1 h
|
||||
makeBooking(dateInMonth(2), '14:00', '15:30'), // 1.5 h
|
||||
makeBooking(dateLastMonth, '09:00', '10:00'), // excluded
|
||||
])
|
||||
|
||||
const result = await service.getStats('user-1')
|
||||
|
||||
expect(result.monthHours).toBeCloseTo(2.5)
|
||||
})
|
||||
|
||||
it('queries only COMPLETED bookings for this user', async () => {
|
||||
mockPrisma.booking.findMany.mockResolvedValue([])
|
||||
|
||||
await service.getStats('user-1')
|
||||
|
||||
expect(mockPrisma.booking.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: 'user-1', status: BookingStatus.COMPLETED },
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user