446 行
14 KiB
JavaScript
446 行
14 KiB
JavaScript
const Fastify = require("fastify");
|
|
|
|
const attendanceRoutes = require("../../../src/routes/attendance");
|
|
const { AppError } = require("../../../src/utils/AppError");
|
|
|
|
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
|
|
const MANAGER_ID = "22222222-2222-4222-8222-222222222222";
|
|
const DEPARTMENT_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
|
const originalFetch = global.fetch;
|
|
|
|
function buildEmployee(overrides = {}) {
|
|
return {
|
|
id: EMPLOYEE_ID,
|
|
employee_no: "E001",
|
|
name: "员工一",
|
|
title: "工程师",
|
|
status: "active",
|
|
department_id: DEPARTMENT_ID,
|
|
direct_manager_id: null,
|
|
department: { id: DEPARTMENT_ID, name: "研发部" },
|
|
wecom_identity_binding: { external_user_id: "wecom-user-1" },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function buildFlag(flagKey, overrideMode = "force_on") {
|
|
return {
|
|
id: `flag-${flagKey}`,
|
|
flag_key: flagKey,
|
|
override_mode: overrideMode,
|
|
rollout_type: "global",
|
|
department_ids: [],
|
|
note: "",
|
|
version: 1,
|
|
created_at: new Date("2026-05-01T00:00:00.000Z"),
|
|
updated_at: new Date("2026-05-01T00:00:00.000Z"),
|
|
};
|
|
}
|
|
|
|
function buildJsonResponse(payload, overrides = {}) {
|
|
return {
|
|
ok: overrides.ok ?? true,
|
|
status: overrides.status ?? 200,
|
|
json: async () => payload,
|
|
};
|
|
}
|
|
|
|
function buildApp(options = {}) {
|
|
const app = Fastify();
|
|
app.setErrorHandler((error, request, reply) => {
|
|
if (error instanceof AppError) {
|
|
return reply.status(error.statusCode).send({
|
|
error: { code: error.code, message: error.message, details: error.details ?? {} },
|
|
});
|
|
}
|
|
return reply.status(error.statusCode ?? 500).send({
|
|
error: { code: error.code ?? "SYS_001", message: error.message ?? "服务器内部错误", details: error.details ?? {} },
|
|
});
|
|
});
|
|
const actor = options.actor ?? {
|
|
id: EMPLOYEE_ID,
|
|
department_id: DEPARTMENT_ID,
|
|
permissionCodes: ["attendance.view_own", "attendance.create", "attendance.submit", "attendance.sync", "attendance.view_all"],
|
|
roleCodes: ["hr"],
|
|
};
|
|
const employees = options.employees ?? [buildEmployee()];
|
|
const syncRuns = [];
|
|
const checkins = [...(options.checkins ?? [])];
|
|
const corrections = [...(options.corrections ?? [])];
|
|
const leaves = [...(options.leaves ?? [])];
|
|
const featureFlagFindUnique = vi.fn(async ({ where }) => {
|
|
if (where.flag_key === "attendance_unified_v1") {
|
|
return buildFlag("attendance_unified_v1", options.unifiedOverrideMode ?? "force_on");
|
|
}
|
|
if (where.flag_key === "attendance_v1") {
|
|
return buildFlag("attendance_v1", "force_on");
|
|
}
|
|
return buildFlag(where.flag_key, "force_on");
|
|
});
|
|
|
|
app.decorate("authenticate", async (request) => {
|
|
request.employee = actor;
|
|
});
|
|
app.decorate("assertEntityPermission", (employee, entityType, action) => {
|
|
const permission = `${entityType === "attendance_correction" ? "attendance" : entityType}.${action}`;
|
|
if (!(employee.permissionCodes ?? []).includes(permission) && !(employee.permissionCodes ?? []).includes("*")) {
|
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission } });
|
|
}
|
|
});
|
|
app.decorate("requirePermission", (permission) => async (request) => {
|
|
await app.authenticate(request);
|
|
if (!(actor.permissionCodes ?? []).includes(permission)) {
|
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
|
}
|
|
});
|
|
app.decorate("config", {
|
|
appTimezone: "Asia/Shanghai",
|
|
attendanceEnabled: true,
|
|
attendanceUnifiedEnabled: true,
|
|
wecomCheckinSyncEnabled: false,
|
|
wecomCheckinSecret: "",
|
|
leaveHolidayCalendarEnabled: false,
|
|
...options.config,
|
|
});
|
|
app.decorate("prisma", {
|
|
featureFlag: { findUnique: featureFlagFindUnique },
|
|
employee: {
|
|
findUnique: vi.fn(async ({ where }) => employees.find((item) => item.id === where.id) ?? null),
|
|
findMany: vi.fn(async ({ where }) =>
|
|
employees.filter((item) => {
|
|
if (where?.status && item.status !== where.status) return false;
|
|
if (where?.id?.in && !where.id.in.includes(item.id)) return false;
|
|
if (where?.OR?.length) {
|
|
return where.OR.some((condition) => {
|
|
if (condition.id && item.id === condition.id) return true;
|
|
if (condition.direct_manager_id && item.direct_manager_id === condition.direct_manager_id) return true;
|
|
return false;
|
|
});
|
|
}
|
|
return true;
|
|
}),
|
|
),
|
|
},
|
|
attendanceRecord: {
|
|
findFirst: vi.fn(),
|
|
findMany: vi.fn(async () => []),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
attendancePolicy: {
|
|
findFirst: vi.fn(),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
legalEntity: {
|
|
findMany: vi.fn(async () => []),
|
|
},
|
|
travelRequest: {
|
|
findMany: vi.fn(async () => []),
|
|
},
|
|
leaveRequest: {
|
|
findMany: vi.fn(async () => leaves),
|
|
},
|
|
wecomCheckinRecord: {
|
|
findMany: vi.fn(async () => checkins),
|
|
createMany: vi.fn(async ({ data }) => {
|
|
checkins.push(...data);
|
|
return { count: data.length };
|
|
}),
|
|
},
|
|
attendanceCorrection: {
|
|
findMany: vi.fn(async () => corrections),
|
|
},
|
|
wecomCheckinSyncRun: {
|
|
create: vi.fn(async ({ data }) => {
|
|
const row = {
|
|
id: `sync-${syncRuns.length + 1}`,
|
|
created_at: new Date("2026-05-17T02:00:00.000Z"),
|
|
updated_at: new Date("2026-05-17T02:00:00.000Z"),
|
|
trigger: buildEmployee(),
|
|
...data,
|
|
};
|
|
syncRuns.push(row);
|
|
return row;
|
|
}),
|
|
update: vi.fn(async ({ where, data }) => {
|
|
const index = syncRuns.findIndex((item) => item.id === where.id);
|
|
syncRuns[index] = {
|
|
...syncRuns[index],
|
|
...data,
|
|
updated_at: new Date("2026-05-17T02:01:00.000Z"),
|
|
trigger: buildEmployee(),
|
|
};
|
|
return syncRuns[index];
|
|
}),
|
|
findMany: vi.fn(async () => syncRuns),
|
|
count: vi.fn(async () => syncRuns.length),
|
|
},
|
|
auditLog: {
|
|
create: vi.fn(async () => ({})),
|
|
},
|
|
});
|
|
|
|
app.register(attendanceRoutes, { prefix: "/api" });
|
|
return { app, prisma: app.prisma, syncRuns };
|
|
}
|
|
|
|
describe("attendance unified routes", () => {
|
|
let currentApp = null;
|
|
|
|
afterEach(async () => {
|
|
global.fetch = originalFetch;
|
|
if (currentApp) {
|
|
await currentApp.close();
|
|
currentApp = null;
|
|
}
|
|
});
|
|
|
|
it("keeps historical OA clock-in API disabled even when legacy flag record is force_on", async () => {
|
|
const { app } = buildApp();
|
|
currentApp = app;
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/attendance/clock-in",
|
|
payload: {},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(409);
|
|
expect(response.json().error.details).toMatchObject({
|
|
featureFlag: "attendance_v1_disabled",
|
|
reason: "env_hard_off",
|
|
});
|
|
});
|
|
|
|
it("returns unified monthly summary with WeCom checkin and OA correction explanation", async () => {
|
|
const { app } = buildApp({
|
|
checkins: [
|
|
{
|
|
id: "checkin-1",
|
|
employee_id: EMPLOYEE_ID,
|
|
checkin_time: new Date("2026-05-11T01:00:00.000Z"),
|
|
checkin_date: new Date("2026-05-11T00:00:00.000Z"),
|
|
checkin_type: "上班打卡",
|
|
exception_type: null,
|
|
source_type: "app",
|
|
result: null,
|
|
},
|
|
],
|
|
corrections: [
|
|
{
|
|
id: "correction-1",
|
|
owner_id: EMPLOYEE_ID,
|
|
kind: "missed_punch",
|
|
status: "approved",
|
|
work_date: new Date("2026-05-12T00:00:00.000Z"),
|
|
missed_punch_at: new Date("2026-05-12T01:00:00.000Z"),
|
|
start_at: null,
|
|
end_at: null,
|
|
reason: "企微缺卡,已说明",
|
|
location_summary: null,
|
|
compensation_policy: null,
|
|
created_at: new Date("2026-05-12T02:00:00.000Z"),
|
|
updated_at: new Date("2026-05-12T02:00:00.000Z"),
|
|
owner: buildEmployee(),
|
|
},
|
|
],
|
|
});
|
|
currentApp = app;
|
|
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: "/api/attendance/monthly?month=2026-05",
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const payload = response.json().data;
|
|
const employee = payload.employees[0];
|
|
const checkinDay = employee.days.find((item) => item.date === "2026-05-11");
|
|
const correctionDay = employee.days.find((item) => item.date === "2026-05-12");
|
|
expect(checkinDay).toMatchObject({ status: "normal", firstCheckinAt: "2026-05-11T01:00:00.000Z" });
|
|
expect(correctionDay).toMatchObject({ status: "corrected", explanation: "补卡" });
|
|
expect(correctionDay.corrections[0]).toMatchObject({ reason: "企微缺卡,已说明" });
|
|
});
|
|
|
|
it("scopes manager monthly summary to self and direct reports", async () => {
|
|
const manager = buildEmployee({
|
|
id: MANAGER_ID,
|
|
employee_no: "M001",
|
|
name: "经理",
|
|
direct_manager_id: null,
|
|
wecom_identity_binding: { external_user_id: "manager-user" },
|
|
});
|
|
const directReport = buildEmployee({ direct_manager_id: MANAGER_ID });
|
|
const outsider = buildEmployee({
|
|
id: "33333333-3333-4333-8333-333333333333",
|
|
employee_no: "E999",
|
|
name: "其他员工",
|
|
direct_manager_id: "44444444-4444-4444-8444-444444444444",
|
|
wecom_identity_binding: { external_user_id: "outsider-user" },
|
|
});
|
|
const { app } = buildApp({
|
|
actor: {
|
|
id: MANAGER_ID,
|
|
department_id: DEPARTMENT_ID,
|
|
permissionCodes: ["attendance.view_own", "attendance.view_all"],
|
|
roleCodes: ["manager"],
|
|
},
|
|
employees: [manager, directReport, outsider],
|
|
});
|
|
currentApp = app;
|
|
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: "/api/attendance/monthly?month=2026-05",
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const names = response.json().data.employees.map((item) => item.employee.name);
|
|
expect(names).toEqual(expect.arrayContaining(["经理", "员工一"]));
|
|
expect(names).not.toContain("其他员工");
|
|
});
|
|
|
|
it("records WeCom sync as skipped when checkin secret or API permission is not configured", async () => {
|
|
const { app, prisma } = buildApp();
|
|
currentApp = app;
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/attendance/wecom/sync",
|
|
payload: {
|
|
startDate: "2026-05-11",
|
|
endDate: "2026-05-12",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().data).toMatchObject({
|
|
skipped: true,
|
|
code: "wecom_checkin_sync_disabled",
|
|
employeeCount: 1,
|
|
boundUserCount: 1,
|
|
recordCount: 0,
|
|
});
|
|
expect(prisma.wecomCheckinSyncRun.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
status: "skipped",
|
|
error_code: "wecom_checkin_sync_disabled",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("records WeCom sync as skipped when no authorized app secret is configured", async () => {
|
|
const { app, prisma } = buildApp({
|
|
config: {
|
|
wecomCheckinSyncEnabled: true,
|
|
wecomCorpId: "corp-id",
|
|
wecomAgentId: "1000001",
|
|
wecomCheckinSecret: "",
|
|
wecomAgentSecret: "",
|
|
},
|
|
});
|
|
currentApp = app;
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/attendance/wecom/sync",
|
|
payload: {
|
|
startDate: "2026-05-11",
|
|
endDate: "2026-05-12",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().data).toMatchObject({
|
|
skipped: true,
|
|
code: "wecom_checkin_secret_missing",
|
|
employeeCount: 1,
|
|
boundUserCount: 1,
|
|
recordCount: 0,
|
|
});
|
|
expect(prisma.wecomCheckinSyncRun.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
status: "skipped",
|
|
error_code: "wecom_checkin_secret_missing",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("uses the authorized workbench app secret fallback for WeCom checkin sync", async () => {
|
|
global.fetch = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(buildJsonResponse({ errcode: 0, access_token: "token-from-workbench" }))
|
|
.mockResolvedValueOnce(
|
|
buildJsonResponse({
|
|
errcode: 0,
|
|
checkindata: [
|
|
{
|
|
userid: "wecom-user-1",
|
|
checkin_time: 1778461200,
|
|
checkin_type: "上班打卡",
|
|
exception_type: "",
|
|
source_type: "app",
|
|
result: "正常",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const { app, prisma } = buildApp({
|
|
config: {
|
|
wecomCheckinSyncEnabled: true,
|
|
wecomCorpId: "corp-id",
|
|
wecomAgentId: "1000001",
|
|
wecomCheckinSecret: "",
|
|
wecomAgentSecret: "workbench-secret",
|
|
},
|
|
});
|
|
currentApp = app;
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/attendance/wecom/sync",
|
|
payload: {
|
|
startDate: "2026-05-11",
|
|
endDate: "2026-05-12",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().data).toMatchObject({
|
|
skipped: false,
|
|
code: "wecom_checkin_fetched",
|
|
employeeCount: 1,
|
|
boundUserCount: 1,
|
|
recordCount: 1,
|
|
});
|
|
expect(String(global.fetch.mock.calls[0][0])).toContain("corpsecret=workbench-secret");
|
|
expect(String(global.fetch.mock.calls[1][0])).toContain("/cgi-bin/checkin/getcheckindata");
|
|
expect(prisma.wecomCheckinRecord.createMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: [
|
|
expect.objectContaining({
|
|
employee_id: EMPLOYEE_ID,
|
|
external_user_id: "wecom-user-1",
|
|
checkin_type: "上班打卡",
|
|
result: "正常",
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
expect(prisma.wecomCheckinSyncRun.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
status: "success",
|
|
record_count: 1,
|
|
error_code: null,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
});
|