Files
photo-live/services/api/test/http-user-source.spec.ts
T
浅念 d732caa739 feat(api): HttpUserSource adapter for uniservice open/v1/users (G4)
全量拉取+phone 全局去重(一人多档跨页)、水位游标不当页码回归守护、
分块续翻缓存;对齐 UserSourcePort 类型。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 16:27:43 +08:00

186 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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');
});
});