186 lines
7.4 KiB
TypeScript
186 lines
7.4 KiB
TypeScript
|
|
// services/api/test/http-user-source.spec.ts —— HttpUserSource 单测(G4 对接)。
|
|||
|
|
// 覆盖:跨页一人多档全局去重、数值水位游标不当页码(回归)、分块续翻 p:N、
|
|||
|
|
// 鉴权/失败路径、无手机号跳过。fetch 全程 mock,不发真实请求。
|
|||
|
|
import { HttpUserSource } from '../src/user-sync/http-user-source';
|
|||
|
|
|
|||
|
|
interface OpenV1User {
|
|||
|
|
id: number;
|
|||
|
|
sn: string;
|
|||
|
|
name: string;
|
|||
|
|
sex: number | null;
|
|||
|
|
email: string;
|
|||
|
|
phone: string;
|
|||
|
|
avatar: string | null;
|
|||
|
|
status: number;
|
|||
|
|
user_type: number;
|
|||
|
|
manager_id: number | null;
|
|||
|
|
company_id: number;
|
|||
|
|
union_id: string | null;
|
|||
|
|
created_at: string;
|
|||
|
|
updated_at: string;
|
|||
|
|
company_name: string;
|
|||
|
|
company_abbr: string;
|
|||
|
|
company_tenant_key: string;
|
|||
|
|
feishu_open_id: string | null;
|
|||
|
|
feishu_user_id: string | null;
|
|||
|
|
feishu_tenant_key: string | null;
|
|||
|
|
last_login_at: string | null;
|
|||
|
|
roles: string[];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const BASE_USER: OpenV1User = {
|
|||
|
|
id: 1, sn: 'S1', name: '张三', sex: null, email: 'z@example.com',
|
|||
|
|
phone: '13800000001', avatar: null, status: 1, user_type: 1, manager_id: null,
|
|||
|
|
company_id: 1, union_id: null, created_at: '2026-09-01T00:00:00Z',
|
|||
|
|
updated_at: '2026-09-20T00:00:00Z', company_name: 'X公司', company_abbr: 'X',
|
|||
|
|
company_tenant_key: 'tk', feishu_open_id: null, feishu_user_id: null,
|
|||
|
|
feishu_tenant_key: null, last_login_at: null, roles: ['user'],
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const user = (over: Partial<OpenV1User> = {}): OpenV1User => ({ ...BASE_USER, ...over });
|
|||
|
|
|
|||
|
|
const okBody = (items: OpenV1User[], total: number, page = 1, pageSize = 200) => ({
|
|||
|
|
success: true,
|
|||
|
|
message: 'ok',
|
|||
|
|
data: { items, total, page, page_size: pageSize },
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/** 最小 fetch Response 替身(仅适配器用到的字段)。 */
|
|||
|
|
function fakeRes(status: number, body: unknown) {
|
|||
|
|
return {
|
|||
|
|
status,
|
|||
|
|
ok: status >= 200 && status < 300,
|
|||
|
|
json: async () => body,
|
|||
|
|
text: async () => JSON.stringify(body),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const originalFetch = global.fetch;
|
|||
|
|
const originalEnv = {
|
|||
|
|
url: process.env.OPEN_API_USERS_URL,
|
|||
|
|
key: process.env.OPEN_API_USERS_KEY,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
describe('HttpUserSource(uniservice /open/v1/users 适配器)', () => {
|
|||
|
|
// 注意不能用大写 URL 作变量名——会遮蔽全局 URL 构造器。
|
|||
|
|
const SOURCE_URL = 'https://uniservice.example/open/v1/users';
|
|||
|
|
let source: HttpUserSource;
|
|||
|
|
let fetchMock: jest.Mock;
|
|||
|
|
|
|||
|
|
beforeEach(() => {
|
|||
|
|
process.env.OPEN_API_USERS_URL = SOURCE_URL;
|
|||
|
|
process.env.OPEN_API_USERS_KEY = 'test-key';
|
|||
|
|
source = new HttpUserSource();
|
|||
|
|
fetchMock = jest.fn();
|
|||
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
afterEach(() => {
|
|||
|
|
global.fetch = originalFetch;
|
|||
|
|
// 保存/恢复而非删除——避免清掉开发者本地 .env 装载的同名值。
|
|||
|
|
if (originalEnv.url === undefined) {
|
|||
|
|
delete process.env.OPEN_API_USERS_URL;
|
|||
|
|
} else {
|
|||
|
|
process.env.OPEN_API_USERS_URL = originalEnv.url;
|
|||
|
|
}
|
|||
|
|
if (originalEnv.key === undefined) {
|
|||
|
|
delete process.env.OPEN_API_USERS_KEY;
|
|||
|
|
} else {
|
|||
|
|
process.env.OPEN_API_USERS_KEY = originalEnv.key;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('跨上游页的一人多档按 phone 全局去重,保留 updated_at 最新行;字段映射正确', async () => {
|
|||
|
|
// echo page_size=2(服务端收窄):page1=[id1,id3]、page2=[id2]——同 phone 两行分落两页。
|
|||
|
|
fetchMock
|
|||
|
|
.mockResolvedValueOnce(fakeRes(200, okBody([
|
|||
|
|
user({ id: 1, name: '张三新档', updated_at: '2026-09-21T00:00:00Z' }),
|
|||
|
|
user({ id: 3, name: '李四', phone: '13800000002', sn: 'S3', updated_at: '2026-09-18T00:00:00Z' }),
|
|||
|
|
], 3, 1, 2)))
|
|||
|
|
.mockResolvedValueOnce(fakeRes(200, okBody([
|
|||
|
|
user({ id: 2, name: '张三旧档', phone: '13800000001', status: 2, updated_at: '2026-09-19T00:00:00Z' }),
|
|||
|
|
], 3, 2, 2)));
|
|||
|
|
|
|||
|
|
const page = await source.listPage();
|
|||
|
|
|
|||
|
|
// 请求形状:page=1 起、page_size=200、status=all、X-Api-Key 头。
|
|||
|
|
const firstUrl = new URL(fetchMock.mock.calls[0][0] as string);
|
|||
|
|
expect(firstUrl.searchParams.get('page')).toBe('1');
|
|||
|
|
expect(firstUrl.searchParams.get('page_size')).toBe('200');
|
|||
|
|
expect(firstUrl.searchParams.get('status')).toBe('all');
|
|||
|
|
expect((fetchMock.mock.calls[0][1] as { headers: Record<string, string> }).headers['X-Api-Key']).toBe('test-key');
|
|||
|
|
|
|||
|
|
// 去重后 2 人;13800000001 保留 id=1(updated_at 最新),status=1 → active。
|
|||
|
|
expect(page.items).toHaveLength(2);
|
|||
|
|
expect(page.nextCursor).toBeNull();
|
|||
|
|
const kept = page.items.find((i) => i.loginAccount === '13800000001');
|
|||
|
|
expect(kept).toMatchObject({
|
|||
|
|
externalUserId: '1',
|
|||
|
|
displayName: '张三新档',
|
|||
|
|
sourceStatus: 'active',
|
|||
|
|
sourceVersion: Math.floor(Date.parse('2026-09-21T00:00:00Z') / 1000),
|
|||
|
|
});
|
|||
|
|
expect(kept?.updatedAt).toEqual(new Date('2026-09-21T00:00:00Z'));
|
|||
|
|
expect(page.items.find((i) => i.externalUserId === '2')).toBeUndefined();
|
|||
|
|
expect(source.sourceSystem).toBe('uniservice');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('数值水位游标不当页码:cursor="1758520000" 仍从第 1 页全量拉取(回归)', async () => {
|
|||
|
|
fetchMock.mockResolvedValueOnce(fakeRes(200, okBody([user()], 1)));
|
|||
|
|
|
|||
|
|
const page = await source.listPage('1758520000');
|
|||
|
|
|
|||
|
|
const url = new URL(fetchMock.mock.calls[0][0] as string);
|
|||
|
|
expect(url.searchParams.get('page')).toBe('1');
|
|||
|
|
expect(page.items).toHaveLength(1);
|
|||
|
|
expect(page.nextCursor).toBeNull();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('分块续翻:>200 行切两块,p:2 用缓存不再回源;新批(水位/空 cursor)重新全量拉', async () => {
|
|||
|
|
const many = Array.from({ length: 205 }, (_, i) =>
|
|||
|
|
user({ id: i + 1, phone: `1380000${String(i).padStart(4, '0')}` }),
|
|||
|
|
);
|
|||
|
|
fetchMock
|
|||
|
|
.mockResolvedValueOnce(fakeRes(200, okBody(many.slice(0, 200), 205)))
|
|||
|
|
.mockResolvedValueOnce(fakeRes(200, okBody(many.slice(200), 205, 2)));
|
|||
|
|
|
|||
|
|
const first = await source.listPage();
|
|||
|
|
expect(first.items).toHaveLength(200);
|
|||
|
|
expect(first.nextCursor).toBe('p:2');
|
|||
|
|
|
|||
|
|
const second = await source.listPage('p:2');
|
|||
|
|
expect(second.items).toHaveLength(5);
|
|||
|
|
expect(second.nextCursor).toBeNull();
|
|||
|
|
expect(fetchMock).toHaveBeenCalledTimes(2); // 续翻走缓存
|
|||
|
|
|
|||
|
|
// 新一批(引擎传数值水位)→ 重新全量拉取。
|
|||
|
|
fetchMock.mockResolvedValueOnce(fakeRes(200, okBody([], 0)));
|
|||
|
|
const fresh = await source.listPage('999');
|
|||
|
|
expect(fresh.items).toHaveLength(0);
|
|||
|
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('失败路径:401 / HTTP 500 / success=false / env 缺失各自抛错', async () => {
|
|||
|
|
fetchMock.mockResolvedValueOnce(fakeRes(401, { success: false, message: 'Invalid API Key', error_code: 'INVALID_API_KEY' }));
|
|||
|
|
await expect(source.listPage()).rejects.toThrow(/Invalid API Key|鉴权失败/);
|
|||
|
|
|
|||
|
|
fetchMock.mockResolvedValueOnce(fakeRes(500, { success: false, message: 'boom' }));
|
|||
|
|
await expect(source.listPage()).rejects.toThrow(/HTTP 500/);
|
|||
|
|
|
|||
|
|
fetchMock.mockResolvedValueOnce(fakeRes(200, { success: false, message: '查询失败' }));
|
|||
|
|
await expect(source.listPage()).rejects.toThrow('查询失败');
|
|||
|
|
|
|||
|
|
delete process.env.OPEN_API_USERS_KEY;
|
|||
|
|
await expect(source.listPage()).rejects.toThrow(/OPEN_API_USERS/);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('无手机号行跳过(warn 不抛),有手机号行照常', async () => {
|
|||
|
|
fetchMock.mockResolvedValueOnce(
|
|||
|
|
fakeRes(200, okBody([user({ id: 1, phone: '' }), user({ id: 2, phone: '13900000002' })], 2)),
|
|||
|
|
);
|
|||
|
|
const page = await source.listPage();
|
|||
|
|
expect(page.items).toHaveLength(1);
|
|||
|
|
expect(page.items[0]?.externalUserId).toBe('2');
|
|||
|
|
});
|
|||
|
|
});
|