1102 lines
37 KiB
TypeScript
1102 lines
37 KiB
TypeScript
/** @jest-environment node */
|
||
|
||
import { prisma } from '@/lib/prisma'
|
||
import mssql from 'mssql'
|
||
import {
|
||
comparePerformanceSurveyEmployeeSyncPreview,
|
||
previewPerformanceSurveyEmployeeSync,
|
||
syncPerformanceSurveyEmployeeByIdentity,
|
||
syncPerformanceSurveyEmployees,
|
||
upsertPerformanceSurveyEmployeeProfileForSync,
|
||
} from '@/lib/performance-survey-report-sync'
|
||
|
||
jest.mock('@/lib/prisma', () => ({
|
||
prisma: {
|
||
$transaction: jest.fn(),
|
||
user: {
|
||
findMany: jest.fn(),
|
||
count: jest.fn(),
|
||
},
|
||
performanceSurveyEmployeeProfile: {
|
||
findMany: jest.fn(),
|
||
findFirst: jest.fn(),
|
||
create: jest.fn(),
|
||
update: jest.fn(),
|
||
},
|
||
performanceSurveyDepartmentProfile: {
|
||
upsert: jest.fn(),
|
||
},
|
||
},
|
||
}))
|
||
|
||
jest.mock('mssql', () => ({
|
||
__esModule: true,
|
||
default: {
|
||
connect: jest.fn(),
|
||
Int: 'Int',
|
||
NVarChar: jest.fn((length: number) => `NVarChar(${length})`),
|
||
},
|
||
}))
|
||
|
||
describe('performance survey employee sync preview', () => {
|
||
const reportEnvKeys = [
|
||
'REPORT_DB_SERVER',
|
||
'REPORT_DB_DATABASE',
|
||
'REPORT_DB_USER',
|
||
'REPORT_DB_PASSWORD',
|
||
] as const
|
||
const originalReportEnv = Object.fromEntries(
|
||
reportEnvKeys.map((key) => [key, process.env[key]]),
|
||
)
|
||
|
||
beforeEach(() => {
|
||
jest.clearAllMocks()
|
||
for (const key of reportEnvKeys) delete process.env[key]
|
||
})
|
||
|
||
afterAll(() => {
|
||
for (const key of reportEnvKeys) {
|
||
const value = originalReportEnv[key]
|
||
if (value === undefined) delete process.env[key]
|
||
else process.env[key] = value
|
||
}
|
||
})
|
||
|
||
test('只读预检会区分新增、业务更新、无变化和素材库兜底', () => {
|
||
const identities = [
|
||
{ userId: 'user-1', dingtalkUserId: 'ding-1', email: 'one@example.com', username: '甲' },
|
||
{ userId: 'user-2', dingtalkUserId: 'ding-2', email: 'two@example.com', username: '乙' },
|
||
{ userId: 'user-3', dingtalkUserId: 'ding-3', email: 'three@example.com', username: '丙' },
|
||
{ userId: 'user-4', dingtalkUserId: 'ding-4', email: 'four@example.com', username: '丁' },
|
||
]
|
||
const rows = [
|
||
{
|
||
dingtalkUserId: 'ding-1',
|
||
dingEmail: 'one@example.com',
|
||
contactName: '甲',
|
||
departmentName: '品牌创意A部',
|
||
},
|
||
{
|
||
dingtalkUserId: 'ding-2',
|
||
dingEmail: 'two@example.com',
|
||
contactName: '乙',
|
||
departmentName: '品牌创意B部',
|
||
},
|
||
{
|
||
dingtalkUserId: 'ding-3',
|
||
dingEmail: 'three@example.com',
|
||
contactName: '丙',
|
||
departmentName: '综合支持部',
|
||
},
|
||
]
|
||
const existingProfiles = [
|
||
{
|
||
id: 'profile-1',
|
||
dingtalkUserId: 'ding-1',
|
||
dingEmail: 'one@example.com',
|
||
jobNumber: null,
|
||
flowerName: null,
|
||
realName: '甲',
|
||
displayName: '甲',
|
||
departmentName: '品牌创意A部',
|
||
departmentPath: '品牌创意A部',
|
||
positionTitle: null,
|
||
positionLevel: null,
|
||
levelType: null,
|
||
isLeadership: false,
|
||
managerDingtalkUserId: null,
|
||
managerJobNumber: null,
|
||
managerName: null,
|
||
employmentStatus: null,
|
||
workPlace: null,
|
||
sourcePayload: {
|
||
source: 'JSReportReplica.rpt',
|
||
materialUserId: 'user-1',
|
||
materialUserEmail: 'one@example.com',
|
||
},
|
||
},
|
||
{
|
||
id: 'profile-2',
|
||
dingtalkUserId: 'ding-2',
|
||
dingEmail: 'two@example.com',
|
||
jobNumber: null,
|
||
flowerName: null,
|
||
realName: '乙',
|
||
displayName: '乙',
|
||
departmentName: '旧部门',
|
||
departmentPath: '旧部门',
|
||
positionTitle: null,
|
||
positionLevel: null,
|
||
levelType: null,
|
||
isLeadership: false,
|
||
managerDingtalkUserId: null,
|
||
managerJobNumber: null,
|
||
managerName: null,
|
||
employmentStatus: null,
|
||
workPlace: null,
|
||
sourcePayload: {
|
||
source: 'JSReportReplica.rpt',
|
||
materialUserId: 'user-2',
|
||
materialUserEmail: 'two@example.com',
|
||
},
|
||
},
|
||
{
|
||
id: 'profile-4',
|
||
dingtalkUserId: 'ding-4',
|
||
dingEmail: 'four@example.com',
|
||
jobNumber: null,
|
||
flowerName: null,
|
||
realName: '丁',
|
||
displayName: '丁',
|
||
departmentName: null,
|
||
departmentPath: null,
|
||
positionTitle: null,
|
||
positionLevel: null,
|
||
levelType: null,
|
||
isLeadership: false,
|
||
managerDingtalkUserId: null,
|
||
managerJobNumber: null,
|
||
managerName: null,
|
||
employmentStatus: null,
|
||
workPlace: null,
|
||
sourcePayload: {
|
||
source: 'material_user_fallback',
|
||
materialUserId: 'user-4',
|
||
materialUserEmail: 'four@example.com',
|
||
},
|
||
},
|
||
]
|
||
|
||
const result = comparePerformanceSurveyEmployeeSyncPreview({
|
||
identities,
|
||
rows,
|
||
existingProfiles,
|
||
})
|
||
|
||
expect(result).toMatchObject({
|
||
fetched: 3,
|
||
matchedAccounts: 3,
|
||
created: 1,
|
||
updated: 1,
|
||
unchanged: 2,
|
||
fallback: 1,
|
||
skipped: 0,
|
||
plannedProfileWrites: 4,
|
||
})
|
||
expect(result.changedFields).toEqual(expect.arrayContaining([
|
||
{ field: 'departmentName', count: 1 },
|
||
{ field: 'departmentPath', count: 1 },
|
||
]))
|
||
})
|
||
|
||
test('只读预检会把新增的兼任部门识别为业务更新', () => {
|
||
const result = comparePerformanceSurveyEmployeeSyncPreview({
|
||
identities: [{
|
||
userId: 'user-qingyun',
|
||
dingtalkUserId: '09053022115891',
|
||
email: 'qingyun@example.com',
|
||
username: '清云',
|
||
}],
|
||
rows: [{
|
||
dingtalkUserId: '09053022115891',
|
||
dingEmail: 'qingyun@example.com',
|
||
contactName: '清云',
|
||
registerMainDeptId: '994456259',
|
||
registerDeptIds: '1085358755|994456259',
|
||
groupDepartmentName: '营销策划中心',
|
||
groupDepartmentPath: '江苏简尚品牌管理有限公司/龙晨品牌中心/营销策划中心',
|
||
resolvedDepartments: [
|
||
{
|
||
sourceDeptId: '994456259',
|
||
departmentName: '营销策划中心',
|
||
departmentPath: '江苏简尚品牌管理有限公司/龙晨品牌中心/营销策划中心',
|
||
isPrimary: true,
|
||
},
|
||
{
|
||
sourceDeptId: '1085358755',
|
||
departmentName: '品牌创意B部',
|
||
departmentPath: '江苏简尚品牌管理有限公司/品牌中心/品牌创意B部',
|
||
isPrimary: false,
|
||
},
|
||
],
|
||
}],
|
||
existingProfiles: [{
|
||
id: 'profile-qingyun',
|
||
dingtalkUserId: '09053022115891',
|
||
dingEmail: 'qingyun@example.com',
|
||
displayName: '清云',
|
||
departmentName: '营销策划中心',
|
||
departmentPath: '江苏简尚品牌管理有限公司/龙晨品牌中心/营销策划中心',
|
||
isLeadership: false,
|
||
isDingtalkAdmin: false,
|
||
isDingtalkBoss: false,
|
||
isDingtalkSenior: false,
|
||
sourcePayload: {
|
||
source: 'JSReportReplica.rpt',
|
||
materialUserId: 'user-qingyun',
|
||
materialUserEmail: 'qingyun@example.com',
|
||
},
|
||
departmentMemberships: [{
|
||
sourceDeptId: '994456259',
|
||
departmentName: '营销策划中心',
|
||
departmentPath: '江苏简尚品牌管理有限公司/龙晨品牌中心/营销策划中心',
|
||
isPrimary: true,
|
||
}],
|
||
}],
|
||
})
|
||
|
||
expect(result).toMatchObject({
|
||
created: 0,
|
||
updated: 1,
|
||
unchanged: 0,
|
||
})
|
||
expect(result.changedFields).toContainEqual({
|
||
field: 'departmentMemberships',
|
||
count: 1,
|
||
})
|
||
})
|
||
|
||
test('只读预检会纳入没有素材库账号的在职花名册人员', () => {
|
||
const result = comparePerformanceSurveyEmployeeSyncPreview({
|
||
identities: [],
|
||
rows: [{
|
||
dingtalkUserId: 'ding-xiangzhang',
|
||
jobNumber: 'JS06002901',
|
||
contactName: '香樟',
|
||
realName: '马震宇',
|
||
departmentName: '品牌设计部',
|
||
positionTitle: '品牌设计师',
|
||
positionLevel: 'P6',
|
||
employmentStatus: '试用',
|
||
}],
|
||
existingProfiles: [],
|
||
})
|
||
|
||
expect(result).toMatchObject({
|
||
fetched: 1,
|
||
matchedAccounts: 0,
|
||
created: 1,
|
||
fallback: 0,
|
||
plannedProfileWrites: 1,
|
||
})
|
||
})
|
||
|
||
test('未配置上游时只返回兜底预检,且不会写入本地数据库', async () => {
|
||
;(prisma.user.findMany as jest.Mock).mockResolvedValue([{
|
||
id: 'user-1',
|
||
email: 'one@example.com',
|
||
username: '甲',
|
||
realName: null,
|
||
dingtalkUserId: 'ding-1',
|
||
department: '综合支持部',
|
||
}])
|
||
;(prisma.user.count as jest.Mock).mockResolvedValue(1)
|
||
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([])
|
||
|
||
const result = await previewPerformanceSurveyEmployeeSync({ limit: 3000 })
|
||
|
||
expect(result).toMatchObject({
|
||
mode: 'preview',
|
||
dryRun: true,
|
||
source: {
|
||
type: 'material_user_fallback',
|
||
upstreamQueried: false,
|
||
},
|
||
now: {
|
||
scannedAccounts: 1,
|
||
databaseWritten: false,
|
||
},
|
||
counts: {
|
||
created: 1,
|
||
fallback: 1,
|
||
deactivated: null,
|
||
},
|
||
deactivation: {
|
||
status: 'unsupported',
|
||
count: null,
|
||
},
|
||
})
|
||
expect(prisma.performanceSurveyEmployeeProfile.create).not.toHaveBeenCalled()
|
||
expect(prisma.performanceSurveyEmployeeProfile.update).not.toHaveBeenCalled()
|
||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('管理员主动预检可只读访问上游,仍不会写入本地数据库', async () => {
|
||
process.env.REPORT_DB_SERVER = 'report.example.test'
|
||
process.env.REPORT_DB_DATABASE = 'report'
|
||
process.env.REPORT_DB_USER = 'readonly'
|
||
process.env.REPORT_DB_PASSWORD = 'secret'
|
||
|
||
;(prisma.user.findMany as jest.Mock).mockResolvedValue([{
|
||
id: 'user-1',
|
||
email: 'one@example.com',
|
||
username: '甲',
|
||
realName: null,
|
||
dingtalkUserId: 'ding-1',
|
||
department: '综合支持部',
|
||
}])
|
||
;(prisma.user.count as jest.Mock).mockResolvedValue(1)
|
||
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([])
|
||
|
||
const pool = {
|
||
request: jest.fn(() => {
|
||
const inputs = new Map<string, unknown>()
|
||
const request: {
|
||
input: jest.Mock
|
||
query: jest.Mock
|
||
} = {
|
||
input: jest.fn(),
|
||
query: jest.fn(),
|
||
}
|
||
request.input.mockImplementation((name: string, ...args: unknown[]) => {
|
||
inputs.set(name, args[args.length - 1])
|
||
return request
|
||
})
|
||
request.query.mockImplementation(async (query: string) => {
|
||
if (query.includes('INFORMATION_SCHEMA.COLUMNS')) {
|
||
const table = inputs.get('table')
|
||
return {
|
||
recordset: table === 'hr_employee'
|
||
? ['userid', 'email', 'job_number', 'name', 'title', 'active']
|
||
.map((COLUMN_NAME) => ({ COLUMN_NAME }))
|
||
: [],
|
||
}
|
||
}
|
||
if (query.includes('[rpt].[hr_employee]')) {
|
||
return {
|
||
recordset: [{
|
||
dingtalkUserId: 'ding-1',
|
||
dingEmail: 'one@example.com',
|
||
jobNumber: 'JS001',
|
||
contactName: '甲',
|
||
contactTitle: '设计师',
|
||
}],
|
||
}
|
||
}
|
||
throw new Error(`未预期的查询:${query}`)
|
||
})
|
||
return request
|
||
}),
|
||
close: jest.fn(),
|
||
}
|
||
;(mssql.connect as jest.Mock).mockResolvedValue(pool)
|
||
|
||
const result = await previewPerformanceSurveyEmployeeSync({ limit: 3000 })
|
||
|
||
expect(result).toMatchObject({
|
||
mode: 'preview',
|
||
dryRun: true,
|
||
source: {
|
||
type: 'report_database',
|
||
access: 'read_only',
|
||
trigger: 'admin_manual_preview',
|
||
upstreamQueried: true,
|
||
},
|
||
now: {
|
||
databaseWritten: false,
|
||
},
|
||
counts: {
|
||
fetched: 1,
|
||
matchedAccounts: 1,
|
||
created: 1,
|
||
fallback: 0,
|
||
deactivated: null,
|
||
},
|
||
})
|
||
expect(mssql.connect).toHaveBeenCalledTimes(1)
|
||
expect(pool.close).toHaveBeenCalledTimes(1)
|
||
expect(prisma.performanceSurveyEmployeeProfile.create).not.toHaveBeenCalled()
|
||
expect(prisma.performanceSurveyEmployeeProfile.update).not.toHaveBeenCalled()
|
||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('整批正式同步会写入没有素材库账号的在职花名册人员', async () => {
|
||
process.env.REPORT_DB_SERVER = 'report.example.test'
|
||
process.env.REPORT_DB_DATABASE = 'report'
|
||
process.env.REPORT_DB_USER = 'readonly'
|
||
process.env.REPORT_DB_PASSWORD = 'secret'
|
||
|
||
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
|
||
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([])
|
||
|
||
const profileCreate = jest.fn().mockResolvedValue({ id: 'profile-xiangzhang' })
|
||
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback({
|
||
performanceSurveyEmployeeProfile: {
|
||
findMany: jest.fn().mockResolvedValue([]),
|
||
findFirst: jest.fn().mockResolvedValue(null),
|
||
create: profileCreate,
|
||
update: jest.fn(),
|
||
},
|
||
performanceSurveyEmployeeDepartmentMembership: {
|
||
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||
createMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||
},
|
||
}))
|
||
|
||
const pool = {
|
||
request: jest.fn(() => {
|
||
const inputs = new Map<string, unknown>()
|
||
const request: {
|
||
input: jest.Mock
|
||
query: jest.Mock
|
||
} = {
|
||
input: jest.fn(),
|
||
query: jest.fn(),
|
||
}
|
||
request.input.mockImplementation((name: string, ...args: unknown[]) => {
|
||
inputs.set(name, args[args.length - 1])
|
||
return request
|
||
})
|
||
request.query.mockImplementation(async (query: string) => {
|
||
if (query.includes('INFORMATION_SCHEMA.COLUMNS')) {
|
||
const table = inputs.get('table')
|
||
return {
|
||
recordset: table === 'hr_employee'
|
||
? ['userid', 'email', 'job_number', 'name', 'title', 'active']
|
||
.map((COLUMN_NAME) => ({ COLUMN_NAME }))
|
||
: [],
|
||
}
|
||
}
|
||
if (query.includes('[rpt].[hr_employee]')) {
|
||
return {
|
||
recordset: [{
|
||
dingtalkUserId: 'ding-xiangzhang',
|
||
jobNumber: 'JS06002901',
|
||
contactName: '香樟',
|
||
contactTitle: '品牌设计师',
|
||
}],
|
||
}
|
||
}
|
||
throw new Error(`未预期的查询:${query}`)
|
||
})
|
||
return request
|
||
}),
|
||
close: jest.fn(),
|
||
}
|
||
;(mssql.connect as jest.Mock).mockResolvedValue(pool)
|
||
|
||
await expect(syncPerformanceSurveyEmployees({ limit: 3000 })).resolves.toMatchObject({
|
||
scannedAccounts: 0,
|
||
scannedProfiles: 1,
|
||
upserted: 1,
|
||
fallback: 0,
|
||
failed: 0,
|
||
})
|
||
expect(profileCreate).toHaveBeenCalledWith({
|
||
data: expect.objectContaining({
|
||
dingtalkUserId: 'ding-xiangzhang',
|
||
jobNumber: 'JS06002901',
|
||
displayName: '香樟',
|
||
positionTitle: '品牌设计师',
|
||
}),
|
||
})
|
||
expect(pool.close).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
test('整批正式同步会纳入只存在于花名表的新员工', async () => {
|
||
process.env.REPORT_DB_SERVER = 'report.example.test'
|
||
process.env.REPORT_DB_DATABASE = 'report'
|
||
process.env.REPORT_DB_USER = 'readonly'
|
||
process.env.REPORT_DB_PASSWORD = 'secret'
|
||
|
||
;(prisma.user.findMany as jest.Mock).mockResolvedValue([{
|
||
id: 'material-user-shuqi',
|
||
email: 'shuqi@example.com',
|
||
username: '叔齐',
|
||
realName: '叔齐',
|
||
dingtalkUserId: '17871916566057990',
|
||
department: '朴美运营',
|
||
}])
|
||
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([])
|
||
|
||
const profileCreate = jest.fn().mockResolvedValue({ id: 'profile-shuqi' })
|
||
const membershipCreateMany = jest.fn().mockResolvedValue({ count: 1 })
|
||
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback({
|
||
performanceSurveyEmployeeProfile: {
|
||
findMany: jest.fn().mockResolvedValue([]),
|
||
findFirst: jest.fn().mockResolvedValue(null),
|
||
create: profileCreate,
|
||
update: jest.fn(),
|
||
},
|
||
performanceSurveyEmployeeDepartmentMembership: {
|
||
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||
createMany: membershipCreateMany,
|
||
},
|
||
}))
|
||
|
||
const pool = {
|
||
request: jest.fn(() => {
|
||
const inputs = new Map<string, unknown>()
|
||
const request: { input: jest.Mock; query: jest.Mock } = {
|
||
input: jest.fn(),
|
||
query: jest.fn(),
|
||
}
|
||
request.input.mockImplementation((name: string, ...args: unknown[]) => {
|
||
inputs.set(name, args[args.length - 1])
|
||
return request
|
||
})
|
||
request.query.mockImplementation(async (query: string) => {
|
||
if (query.includes('INFORMATION_SCHEMA.COLUMNS')) {
|
||
const table = inputs.get('table')
|
||
const columns = table === 'hr_employee'
|
||
? ['userid', 'job_number', 'name', 'active']
|
||
: table === 'hr_flowerName_book'
|
||
? ['flowerNameId', 'jobNum', 'name', 'flowerName', 'dept', 'deptId', 'job', 'jobLevel', 'empStatus', 'status', 'leaveDate']
|
||
: []
|
||
return { recordset: columns.map((COLUMN_NAME) => ({ COLUMN_NAME })) }
|
||
}
|
||
if (query.includes(';WITH input_depts')) {
|
||
return {
|
||
recordset: [{
|
||
dept_id: 1049297345,
|
||
path: '江苏简尚品牌管理有限公司/运营中心G/朴美项目部/运营一组',
|
||
}],
|
||
}
|
||
}
|
||
if (query.includes('[rpt].[hr_employee]')) return { recordset: [] }
|
||
if (query.includes('[rpt].[hr_flowerName_book]')) {
|
||
expect(query).toContain('[flowerNameId]')
|
||
return {
|
||
recordset: [{
|
||
dingtalkUserId: '17871916566057990',
|
||
registerJobNumber: 'JS26082002',
|
||
flowerBookName: '黄鸿翔',
|
||
flowerBookFlowerName: '叔齐',
|
||
flowerBookDept: '["运营中心G","朴美项目部","运营一组"]',
|
||
flowerBookDeptId: '[911666246,1007911425,1049297345]',
|
||
flowerBookJob: '运营',
|
||
flowerBookJobLevel: 'P4',
|
||
flowerBookEmployeeStatus: '试用',
|
||
}],
|
||
}
|
||
}
|
||
throw new Error(`未预期的查询:${query}`)
|
||
})
|
||
return request
|
||
}),
|
||
close: jest.fn(),
|
||
}
|
||
;(mssql.connect as jest.Mock).mockResolvedValue(pool)
|
||
|
||
await expect(syncPerformanceSurveyEmployees({ limit: 3000 })).resolves.toMatchObject({
|
||
scannedAccounts: 1,
|
||
scannedProfiles: 1,
|
||
upserted: 1,
|
||
fallback: 0,
|
||
failed: 0,
|
||
})
|
||
expect(profileCreate).toHaveBeenCalledWith({
|
||
data: expect.objectContaining({
|
||
dingtalkUserId: '17871916566057990',
|
||
jobNumber: 'JS26082002',
|
||
realName: '黄鸿翔',
|
||
flowerName: '叔齐',
|
||
displayName: '叔齐',
|
||
departmentName: '运营一组',
|
||
departmentPath: '江苏简尚品牌管理有限公司/运营中心G/朴美项目部/运营一组',
|
||
positionTitle: '运营',
|
||
positionLevel: 'P4',
|
||
sourcePayload: expect.objectContaining({
|
||
materialUserId: 'material-user-shuqi',
|
||
}),
|
||
}),
|
||
})
|
||
expect(membershipCreateMany).toHaveBeenCalledWith({
|
||
data: [expect.objectContaining({
|
||
employeeProfileId: 'profile-shuqi',
|
||
sourceDeptId: '1049297345',
|
||
isPrimary: true,
|
||
})],
|
||
})
|
||
})
|
||
|
||
test('单人刷新会按钉钉 ID 精确补查花名表独有员工', async () => {
|
||
process.env.REPORT_DB_SERVER = 'report.example.test'
|
||
process.env.REPORT_DB_DATABASE = 'report'
|
||
process.env.REPORT_DB_USER = 'readonly'
|
||
process.env.REPORT_DB_PASSWORD = 'secret'
|
||
|
||
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([])
|
||
const profileCreate = jest.fn().mockResolvedValue({ id: 'profile-shuqi' })
|
||
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback({
|
||
performanceSurveyEmployeeProfile: {
|
||
findMany: jest.fn().mockResolvedValue([]),
|
||
findFirst: jest.fn().mockResolvedValue(null),
|
||
create: profileCreate,
|
||
update: jest.fn(),
|
||
},
|
||
performanceSurveyEmployeeDepartmentMembership: {
|
||
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||
createMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||
},
|
||
}))
|
||
|
||
const pool = {
|
||
request: jest.fn(() => {
|
||
const inputs = new Map<string, unknown>()
|
||
const request: { input: jest.Mock; query: jest.Mock } = {
|
||
input: jest.fn(),
|
||
query: jest.fn(),
|
||
}
|
||
request.input.mockImplementation((name: string, ...args: unknown[]) => {
|
||
inputs.set(name, args[args.length - 1])
|
||
return request
|
||
})
|
||
request.query.mockImplementation(async (query: string) => {
|
||
if (query.includes('INFORMATION_SCHEMA.COLUMNS')) {
|
||
const table = inputs.get('table')
|
||
const columns = table === 'hr_employee'
|
||
? ['userid', 'job_number', 'name', 'active']
|
||
: table === 'hr_flowerName_book'
|
||
? ['flowerNameId', 'jobNum', 'name', 'flowerName', 'dept', 'deptId', 'job', 'jobLevel', 'empStatus', 'status', 'leaveDate']
|
||
: []
|
||
return { recordset: columns.map((COLUMN_NAME) => ({ COLUMN_NAME })) }
|
||
}
|
||
if (query.includes(';WITH input_depts')) {
|
||
return {
|
||
recordset: [{
|
||
dept_id: 1049297345,
|
||
path: '江苏简尚品牌管理有限公司/运营中心G/朴美项目部/运营一组',
|
||
}],
|
||
}
|
||
}
|
||
if (query.includes('[rpt].[hr_employee] e')) return { recordset: [] }
|
||
if (query.includes('[rpt].[hr_flowerName_book] f')) {
|
||
expect(query).toContain('[flowerNameId] = @dingtalkUserId')
|
||
expect(inputs.get('dingtalkUserId')).toBe('17871916566057990')
|
||
return {
|
||
recordset: [{
|
||
dingtalkUserId: '17871916566057990',
|
||
registerJobNumber: 'JS26082002',
|
||
flowerBookName: '黄鸿翔',
|
||
flowerBookFlowerName: '叔齐',
|
||
flowerBookDept: '["运营中心G","朴美项目部","运营一组"]',
|
||
flowerBookDeptId: '[911666246,1007911425,1049297345]',
|
||
flowerBookJob: '运营',
|
||
flowerBookJobLevel: 'P4',
|
||
}],
|
||
}
|
||
}
|
||
throw new Error(`未预期的查询:${query}`)
|
||
})
|
||
return request
|
||
}),
|
||
close: jest.fn(),
|
||
}
|
||
;(mssql.connect as jest.Mock).mockResolvedValue(pool)
|
||
|
||
await syncPerformanceSurveyEmployeeByIdentity({
|
||
userId: 'material-user-shuqi',
|
||
dingtalkUserId: '17871916566057990',
|
||
email: 'shuqi@example.com',
|
||
username: '叔齐',
|
||
})
|
||
|
||
expect(profileCreate).toHaveBeenCalledWith({
|
||
data: expect.objectContaining({
|
||
dingtalkUserId: '17871916566057990',
|
||
realName: '黄鸿翔',
|
||
flowerName: '叔齐',
|
||
departmentPath: '江苏简尚品牌管理有限公司/运营中心G/朴美项目部/运营一组',
|
||
sourcePayload: expect.objectContaining({
|
||
materialUserId: 'material-user-shuqi',
|
||
}),
|
||
}),
|
||
})
|
||
})
|
||
|
||
test('单人正式同步会保存全部所属部门和钉钉身份字段,且不映射为系统权限', async () => {
|
||
process.env.REPORT_DB_SERVER = 'report.example.test'
|
||
process.env.REPORT_DB_DATABASE = 'report'
|
||
process.env.REPORT_DB_USER = 'readonly'
|
||
process.env.REPORT_DB_PASSWORD = 'secret'
|
||
|
||
;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue(null)
|
||
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
|
||
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([])
|
||
|
||
const profileCreate = jest.fn().mockResolvedValue({ id: 'profile-qingyun' })
|
||
const membershipDeleteMany = jest.fn().mockResolvedValue({ count: 0 })
|
||
const membershipCreateMany = jest.fn().mockResolvedValue({ count: 2 })
|
||
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback({
|
||
performanceSurveyEmployeeProfile: {
|
||
findMany: jest.fn().mockResolvedValue([]),
|
||
findFirst: jest.fn().mockResolvedValue(null),
|
||
create: profileCreate,
|
||
update: jest.fn(),
|
||
},
|
||
performanceSurveyEmployeeDepartmentMembership: {
|
||
deleteMany: membershipDeleteMany,
|
||
createMany: membershipCreateMany,
|
||
},
|
||
}))
|
||
|
||
const pool = {
|
||
request: jest.fn(() => {
|
||
const inputs = new Map<string, unknown>()
|
||
const request: {
|
||
input: jest.Mock
|
||
query: jest.Mock
|
||
} = {
|
||
input: jest.fn(),
|
||
query: jest.fn(),
|
||
}
|
||
request.input.mockImplementation((name: string, ...args: unknown[]) => {
|
||
inputs.set(name, args[args.length - 1])
|
||
return request
|
||
})
|
||
request.query.mockImplementation(async (query: string) => {
|
||
if (query.includes('INFORMATION_SCHEMA.COLUMNS')) {
|
||
const table = inputs.get('table')
|
||
const columns = table === 'hr_employee'
|
||
? [
|
||
'userid',
|
||
'email',
|
||
'job_number',
|
||
'name',
|
||
'title',
|
||
'active',
|
||
'dept_id_list',
|
||
'admin',
|
||
'boss',
|
||
'senior',
|
||
]
|
||
: table === 'hr_employee_register'
|
||
? [
|
||
'userid',
|
||
'sys00-name',
|
||
'sys00-flowerName',
|
||
'sys00-jobNumber',
|
||
'sys00-position',
|
||
'sys01-positionLevel',
|
||
'sys00-deptIds',
|
||
'sys00-mainDeptId',
|
||
]
|
||
: table === 'hr_flowerName_book'
|
||
? ['jobNum', 'flowerName', 'deptId']
|
||
: []
|
||
return { recordset: columns.map((COLUMN_NAME) => ({ COLUMN_NAME })) }
|
||
}
|
||
if (query.includes(';WITH input_depts')) {
|
||
return {
|
||
recordset: [
|
||
{
|
||
dept_id: 994456259,
|
||
path: '江苏简尚品牌管理有限公司/龙晨品牌中心/营销策划中心',
|
||
},
|
||
{
|
||
dept_id: 1085358755,
|
||
path: '江苏简尚品牌管理有限公司/品牌中心/品牌创意B部',
|
||
},
|
||
],
|
||
}
|
||
}
|
||
if (query.includes('[rpt].[hr_employee] e')) {
|
||
expect(query).toContain('AS "isDingtalkAdmin"')
|
||
expect(query).toContain('AS "isDingtalkBoss"')
|
||
expect(query).toContain('AS "isDingtalkSenior"')
|
||
return {
|
||
recordset: [{
|
||
dingtalkUserId: '09053022115891',
|
||
dingEmail: 'qingyun@example.com',
|
||
jobNumber: 'JS15030101',
|
||
contactName: '清云',
|
||
contactTitle: '营销策划主管',
|
||
employeeDeptIdList: '994456259,1085358755',
|
||
registerName: '清云',
|
||
flowerName: '清云',
|
||
registerJobNumber: 'JS15030101',
|
||
positionTitle: '营销策划主管',
|
||
positionLevel: 'M1',
|
||
registerDeptIds: '1085358755|994456259',
|
||
registerMainDeptId: '994456259',
|
||
flowerBookFlowerName: '清云',
|
||
flowerBookDeptId: '[994121400,994456259]',
|
||
isDingtalkAdmin: true,
|
||
isDingtalkBoss: false,
|
||
isDingtalkSenior: true,
|
||
}],
|
||
}
|
||
}
|
||
throw new Error(`未预期的查询:${query}`)
|
||
})
|
||
return request
|
||
}),
|
||
close: jest.fn(),
|
||
}
|
||
;(mssql.connect as jest.Mock).mockResolvedValue(pool)
|
||
|
||
await syncPerformanceSurveyEmployeeByIdentity({
|
||
userId: 'material-user-qingyun',
|
||
dingtalkUserId: '09053022115891',
|
||
email: 'qingyun@example.com',
|
||
username: '清云',
|
||
})
|
||
|
||
expect(profileCreate).toHaveBeenCalledWith({
|
||
data: expect.objectContaining({
|
||
displayName: '清云',
|
||
departmentName: '营销策划中心',
|
||
departmentPath: '江苏简尚品牌管理有限公司/龙晨品牌中心/营销策划中心',
|
||
positionLevel: 'M1',
|
||
levelType: 'M',
|
||
isLeadership: true,
|
||
isDingtalkAdmin: true,
|
||
isDingtalkBoss: false,
|
||
isDingtalkSenior: true,
|
||
}),
|
||
})
|
||
expect(profileCreate.mock.calls[0][0].data).not.toHaveProperty('departmentMemberships')
|
||
expect(membershipDeleteMany).toHaveBeenCalledWith({
|
||
where: { employeeProfileId: 'profile-qingyun' },
|
||
})
|
||
expect(membershipCreateMany).toHaveBeenCalledWith({
|
||
data: expect.arrayContaining([
|
||
expect.objectContaining({
|
||
employeeProfileId: 'profile-qingyun',
|
||
sourceDeptId: '994456259',
|
||
departmentName: '营销策划中心',
|
||
isPrimary: true,
|
||
}),
|
||
expect.objectContaining({
|
||
employeeProfileId: 'profile-qingyun',
|
||
sourceDeptId: '1085358755',
|
||
departmentName: '品牌创意B部',
|
||
isPrimary: false,
|
||
}),
|
||
]),
|
||
})
|
||
expect(membershipCreateMany.mock.calls[0][0].data).toHaveLength(2)
|
||
expect(pool.close).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
test('正式同步会合并钉钉 ID 与工号拆分出的空档案,再保存完整身份', async () => {
|
||
const dingtalkProfile = {
|
||
id: 'profile-dingtalk',
|
||
dingtalkUserId: 'ding-huai-de',
|
||
jobNumber: null,
|
||
dingEmail: 'huai.de@example.com',
|
||
sourcePayload: {
|
||
source: 'JSReportReplica.rpt',
|
||
materialUserId: 'material-user-huai-de',
|
||
materialUserEmail: 'huai.de@example.com',
|
||
},
|
||
createdAt: new Date('2026-07-01T00:00:00.000Z'),
|
||
}
|
||
const jobNumberProfile = {
|
||
id: 'profile-job-number',
|
||
dingtalkUserId: null,
|
||
jobNumber: 'JS26070601',
|
||
dingEmail: null,
|
||
createdAt: new Date('2026-07-02T00:00:00.000Z'),
|
||
}
|
||
const profileDelete = jest.fn().mockResolvedValue(jobNumberProfile)
|
||
const profileUpdate = jest.fn().mockResolvedValue({
|
||
id: dingtalkProfile.id,
|
||
dingtalkUserId: 'ding-huai-de',
|
||
jobNumber: 'JS26070601',
|
||
displayName: '怀德',
|
||
})
|
||
const zeroCount = jest.fn().mockResolvedValue(0)
|
||
const membershipDeleteMany = jest.fn().mockResolvedValue({ count: 0 })
|
||
const membershipCreateMany = jest.fn().mockResolvedValue({ count: 1 })
|
||
|
||
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback({
|
||
performanceSurveyEmployeeProfile: {
|
||
findMany: jest.fn().mockResolvedValue([dingtalkProfile, jobNumberProfile]),
|
||
findFirst: jest.fn(),
|
||
create: jest.fn(),
|
||
update: profileUpdate,
|
||
delete: profileDelete,
|
||
},
|
||
performanceSurveyAssignment: { count: zeroCount },
|
||
performanceSurveyTarget: { count: zeroCount },
|
||
performanceSurveyResponse: { count: zeroCount },
|
||
performanceSurveyWorkItem: { count: zeroCount },
|
||
performanceSurveyWorkItemScore: { count: zeroCount },
|
||
performanceSurveyNotification: { count: zeroCount },
|
||
performanceSurveyEmployeePositionAssignment: { count: zeroCount },
|
||
performanceSurvey: { count: zeroCount },
|
||
performanceSurveyEmployeeDepartmentMembership: {
|
||
deleteMany: membershipDeleteMany,
|
||
createMany: membershipCreateMany,
|
||
},
|
||
}))
|
||
|
||
await upsertPerformanceSurveyEmployeeProfileForSync({
|
||
dingtalkUserId: 'ding-huai-de',
|
||
dingEmail: 'huai.de@example.com',
|
||
jobNumber: 'JS26070601',
|
||
flowerName: '怀德',
|
||
realName: '吾正鹏',
|
||
displayName: '怀德',
|
||
departmentName: '京东组',
|
||
departmentPath: '运营中心G/运营二部(HZ)/京东组',
|
||
positionTitle: '运营',
|
||
positionLevel: 'P4',
|
||
levelType: 'P',
|
||
isLeadership: false,
|
||
isDingtalkAdmin: false,
|
||
isDingtalkBoss: false,
|
||
isDingtalkSenior: false,
|
||
managerDingtalkUserId: null,
|
||
managerJobNumber: null,
|
||
managerName: null,
|
||
employmentStatus: null,
|
||
workPlace: null,
|
||
sourcePayload: {
|
||
source: 'JSReportReplica.rpt',
|
||
syncedFields: {
|
||
hasDingtalkUserId: true,
|
||
hasDingEmail: true,
|
||
jobNumber: 'JS26070601',
|
||
displayName: '怀德',
|
||
departmentName: '京东组',
|
||
departmentPath: '运营中心G/运营二部(HZ)/京东组',
|
||
primaryDeptId: '1055268872',
|
||
departmentIds: ['1055268872'],
|
||
employeeDeptIdList: '1055268872',
|
||
registerDeptIds: '1055268872',
|
||
registerMainDeptId: '1055268872',
|
||
flowerBookDeptId: '1055268872',
|
||
positionLevel: 'P4',
|
||
levelType: 'P',
|
||
flowerBookRole: null,
|
||
isDingtalkAdmin: false,
|
||
isDingtalkBoss: false,
|
||
isDingtalkSenior: false,
|
||
},
|
||
},
|
||
syncedAt: new Date(),
|
||
departmentMemberships: [{
|
||
sourceDeptId: '1055268872',
|
||
departmentName: '京东组',
|
||
departmentPath: '运营中心G/运营二部(HZ)/京东组',
|
||
isPrimary: true,
|
||
}],
|
||
})
|
||
|
||
expect(profileDelete).toHaveBeenCalledWith({
|
||
where: { id: 'profile-job-number' },
|
||
})
|
||
expect(profileUpdate).toHaveBeenCalledWith({
|
||
where: { id: 'profile-dingtalk' },
|
||
data: expect.objectContaining({
|
||
dingtalkUserId: 'ding-huai-de',
|
||
jobNumber: 'JS26070601',
|
||
flowerName: '怀德',
|
||
realName: '吾正鹏',
|
||
displayName: '怀德',
|
||
sourcePayload: expect.objectContaining({
|
||
source: 'JSReportReplica.rpt',
|
||
materialUserId: 'material-user-huai-de',
|
||
materialUserEmail: 'huai.de@example.com',
|
||
}),
|
||
}),
|
||
})
|
||
expect(membershipDeleteMany).toHaveBeenCalledWith({
|
||
where: { employeeProfileId: 'profile-dingtalk' },
|
||
})
|
||
expect(membershipCreateMany).toHaveBeenCalledWith({
|
||
data: [expect.objectContaining({
|
||
employeeProfileId: 'profile-dingtalk',
|
||
sourceDeptId: '1055268872',
|
||
isPrimary: true,
|
||
})],
|
||
})
|
||
})
|
||
|
||
test('拆分档案都已承载考核数据时会中止自动合并,避免误改历史记录', async () => {
|
||
const profileDelete = jest.fn()
|
||
const profileUpdate = jest.fn()
|
||
const zeroCount = jest.fn().mockResolvedValue(0)
|
||
const referencedCount = jest.fn().mockResolvedValue(1)
|
||
|
||
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback({
|
||
performanceSurveyEmployeeProfile: {
|
||
findMany: jest.fn().mockResolvedValue([
|
||
{
|
||
id: 'profile-dingtalk',
|
||
dingtalkUserId: 'ding-conflict',
|
||
jobNumber: null,
|
||
createdAt: new Date('2026-07-01T00:00:00.000Z'),
|
||
},
|
||
{
|
||
id: 'profile-job-number',
|
||
dingtalkUserId: null,
|
||
jobNumber: 'JS-CONFLICT',
|
||
createdAt: new Date('2026-07-02T00:00:00.000Z'),
|
||
},
|
||
]),
|
||
findFirst: jest.fn(),
|
||
create: jest.fn(),
|
||
update: profileUpdate,
|
||
delete: profileDelete,
|
||
},
|
||
performanceSurveyAssignment: { count: referencedCount },
|
||
performanceSurveyTarget: { count: zeroCount },
|
||
performanceSurveyResponse: { count: zeroCount },
|
||
performanceSurveyWorkItem: { count: zeroCount },
|
||
performanceSurveyWorkItemScore: { count: zeroCount },
|
||
performanceSurveyNotification: { count: zeroCount },
|
||
performanceSurveyEmployeePositionAssignment: { count: zeroCount },
|
||
performanceSurvey: { count: zeroCount },
|
||
performanceSurveyEmployeeDepartmentMembership: {
|
||
deleteMany: jest.fn(),
|
||
createMany: jest.fn(),
|
||
},
|
||
}))
|
||
|
||
await expect(upsertPerformanceSurveyEmployeeProfileForSync({
|
||
dingtalkUserId: 'ding-conflict',
|
||
dingEmail: null,
|
||
jobNumber: 'JS-CONFLICT',
|
||
flowerName: '冲突人员',
|
||
realName: '冲突人员',
|
||
displayName: '冲突人员',
|
||
departmentName: null,
|
||
departmentPath: null,
|
||
positionTitle: null,
|
||
positionLevel: null,
|
||
levelType: null,
|
||
isLeadership: false,
|
||
isDingtalkAdmin: false,
|
||
isDingtalkBoss: false,
|
||
isDingtalkSenior: false,
|
||
managerDingtalkUserId: null,
|
||
managerJobNumber: null,
|
||
managerName: null,
|
||
employmentStatus: null,
|
||
workPlace: null,
|
||
sourcePayload: {
|
||
source: 'JSReportReplica.rpt',
|
||
syncedFields: {
|
||
hasDingtalkUserId: true,
|
||
hasDingEmail: false,
|
||
jobNumber: 'JS-CONFLICT',
|
||
displayName: '冲突人员',
|
||
departmentName: null,
|
||
departmentPath: null,
|
||
primaryDeptId: null,
|
||
departmentIds: [],
|
||
employeeDeptIdList: null,
|
||
registerDeptIds: null,
|
||
registerMainDeptId: null,
|
||
flowerBookDeptId: null,
|
||
positionLevel: null,
|
||
levelType: null,
|
||
flowerBookRole: null,
|
||
isDingtalkAdmin: false,
|
||
isDingtalkBoss: false,
|
||
isDingtalkSenior: false,
|
||
},
|
||
},
|
||
syncedAt: new Date(),
|
||
departmentMemberships: [],
|
||
})).rejects.toThrow('多条已承载考核数据的档案')
|
||
|
||
expect(profileDelete).not.toHaveBeenCalled()
|
||
expect(profileUpdate).not.toHaveBeenCalled()
|
||
})
|
||
})
|