2026-05-25 16:20:26 +08:00

173 lines
4.8 KiB
TypeScript

import { NextRequest } from 'next/server'
import { POST } from '../route'
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: jest.fn(),
update: jest.fn(),
create: jest.fn(),
},
dingtalkLoginToken: {
create: jest.fn(),
},
},
}))
jest.mock('bcryptjs', () => ({
hash: jest.fn().mockResolvedValue('hashed-random-password'),
}))
const { prisma } = require('@/lib/prisma')
const bcrypt = require('bcryptjs')
function mockDingtalkFetch() {
;(global.fetch as jest.Mock)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ accessToken: 'user-access-token' }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, access_token: 'app-access-token' }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ unionId: 'union-1' }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ errcode: 0, result: { userid: 'dingtalk-user-1' } }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
errcode: 0,
result: {
name: '张三',
email: 'zhangsan@example.com',
mobile: '13800000000',
avatar: 'https://example.com/avatar.png',
},
}),
})
}
describe('POST /api/dingtalk/login', () => {
const originalEnv = process.env
beforeEach(() => {
jest.resetAllMocks()
bcrypt.hash.mockResolvedValue('hashed-random-password')
process.env = {
...originalEnv,
DINGTALK_CLIENT_ID: 'client-id',
DINGTALK_CLIENT_SECRET: 'client-secret',
}
global.fetch = jest.fn()
})
afterAll(() => {
process.env = originalEnv
})
it('缺少钉钉配置时直接拒绝,不请求外部接口', async () => {
delete process.env.DINGTALK_CLIENT_ID
delete process.env.DINGTALK_CLIENT_SECRET
const response = await POST(
new NextRequest('http://localhost/api/dingtalk/login', {
method: 'POST',
body: JSON.stringify({ code: 'auth-code' }),
})
)
const data = await response.json()
expect(response.status).toBe(503)
expect(data.message).toContain('未配置钉钉登录')
expect(global.fetch).not.toHaveBeenCalled()
})
it('绑定同名老用户时不覆盖原密码,并返回一次性登录票据', async () => {
mockDingtalkFetch()
const existingUser = {
id: 'user-1',
email: 'old@example.com',
username: '张三',
password: 'old-password-hash',
dingtalkUserId: null,
realName: null,
avatar: null,
phone: null,
department: '设计部',
role: 'USER',
isActive: true,
}
prisma.user.findUnique
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(existingUser)
prisma.user.update.mockResolvedValue({
...existingUser,
dingtalkUserId: 'dingtalk-user-1',
loginType: 'dingtalk',
realName: '张三',
})
prisma.dingtalkLoginToken.create.mockResolvedValue({ id: 'token-row' })
const response = await POST(
new NextRequest('http://localhost/api/dingtalk/login', {
method: 'POST',
body: JSON.stringify({ code: 'auth-code' }),
})
)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.loginToken).toEqual(expect.any(String))
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'user-1' },
data: expect.not.objectContaining({ password: expect.anything() }),
})
expect(bcrypt.hash).not.toHaveBeenCalled()
})
it('创建钉钉新用户时使用随机不可知密码,不写固定密码', async () => {
mockDingtalkFetch()
prisma.user.findUnique
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
prisma.user.create.mockResolvedValue({
id: 'user-2',
email: 'zhangsan@example.com',
username: '张三',
realName: '张三',
avatar: 'https://example.com/avatar.png',
role: 'USER',
department: '',
isActive: false,
})
prisma.dingtalkLoginToken.create.mockResolvedValue({ id: 'profile-token-row' })
const response = await POST(
new NextRequest('http://localhost/api/dingtalk/login', {
method: 'POST',
body: JSON.stringify({ code: 'auth-code' }),
})
)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.needCompleteProfile).toBe(true)
expect(data.profileToken).toEqual(expect.any(String))
expect(bcrypt.hash).toHaveBeenCalledWith(expect.not.stringContaining('dingtalk_auto_login'), 10)
expect(prisma.user.create).toHaveBeenCalledWith({
data: expect.objectContaining({
password: 'hashed-random-password',
loginType: 'dingtalk',
dingtalkUserId: 'dingtalk-user-1',
}),
})
})
})