fix: harden DingTalk student synchronization

This commit is contained in:
2026-07-18 14:51:53 +08:00
parent d25e451b61
commit c11f6bb614
15 changed files with 758 additions and 356 deletions

View File

@@ -176,14 +176,18 @@ describe('DingTalkService — attendance machine only group', () => {
});
describe('DingTalkService — department user pagination boundaries', () => {
type PrivateDingTalkService = {
getDeptUsers(token: string, deptId: number): Promise<unknown[]>;
};
afterEach(() => {
jest.restoreAllMocks();
global.fetch = undefined as unknown as typeof fetch;
});
it('stops when DingTalk says there is another page but omits the next cursor', async () => {
it('fails when DingTalk says there is another page but omits the next cursor', async () => {
const service = new DingTalkService({} as never, {} as never);
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({
errcode: 0,
errmsg: 'ok',
@@ -194,13 +198,14 @@ describe('DingTalkService — department user pagination boundaries', () => {
}),
}) as jest.MockedFunction<typeof fetch>;
await expect((service as any).getDeptUsers('token', 1)).resolves.toHaveLength(1);
await expect((service as unknown as PrivateDingTalkService).getDeptUsers('token', 1)).rejects.toThrow('分页游标未前进');
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it('stops when the next cursor repeats the current cursor', async () => {
it('fails when the next cursor repeats the current cursor', async () => {
const service = new DingTalkService({} as never, {} as never);
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({
errcode: 0,
errmsg: 'ok',
@@ -208,7 +213,56 @@ describe('DingTalkService — department user pagination boundaries', () => {
}),
}) as jest.MockedFunction<typeof fetch>;
await expect((service as any).getDeptUsers('token', 1)).resolves.toEqual([]);
await expect((service as unknown as PrivateDingTalkService).getDeptUsers('token', 1)).rejects.toThrow('分页游标未前进');
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it('fails on a DingTalk API error instead of returning a partial user list', async () => {
const service = new DingTalkService({} as never, {} as never);
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({ errcode: 40035, errmsg: 'invalid department' }),
}) as jest.MockedFunction<typeof fetch>;
await expect((service as unknown as PrivateDingTalkService).getDeptUsers('token', 9)).rejects.toThrow('invalid department');
});
it('keeps a multi-department user visible in the selected subtree', async () => {
const service = new DingTalkService({} as never, {} as never);
const privateService = service as unknown as {
isConfigured(): Promise<boolean>;
getAccessToken(): Promise<string>;
getDeptInfo(token: string, deptId: number): Promise<{ name: string; parent_id: number }>;
buildDeptNode(token: string, deptId: number, name: string, parentId: number): Promise<{
id: number;
name: string;
parentId: number;
children: [];
}>;
getDeptUsers(token: string, deptId: number): Promise<Array<{
userid: string;
name: string;
mobile: string;
dept_id_list: number[];
}>>;
};
jest.spyOn(privateService, 'isConfigured').mockResolvedValue(true);
jest.spyOn(privateService, 'getAccessToken').mockResolvedValue('token');
jest.spyOn(privateService, 'getDeptInfo').mockResolvedValue({ name: '子部门', parent_id: 1 });
jest.spyOn(privateService, 'buildDeptNode').mockResolvedValue({
id: 2,
name: '子部门',
parentId: 1,
children: [],
});
jest.spyOn(privateService, 'getDeptUsers').mockResolvedValue([
{ userid: 'u1', name: 'Alice', mobile: '', dept_id_list: [2, 99] },
]);
const tree = await service.fetchOrgTreeWithUsers(2);
expect(tree[0].users).toEqual([
expect.objectContaining({ userid: 'u1', deptIds: [2, 99] }),
]);
});
});