Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
278 lines
9.8 KiB
TypeScript
278 lines
9.8 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
|
import type {
|
|
OidbBase,
|
|
OidbFriend,
|
|
OidbSvcTrpcTcp0xFD4_1Response,
|
|
} from '@snowluma/proto-defs/oidb';
|
|
import type {
|
|
OidbUserInfoRequest,
|
|
OidbUserInfoResponse,
|
|
} from '@snowluma/proto-defs/oidb-actions/base';
|
|
import { IdentityService } from '@snowluma/protocol/identity-service';
|
|
import { Bridge } from '../../src/bridge/bridge';
|
|
|
|
function deferred() {
|
|
let resolve!: () => void;
|
|
let reject!: (reason: Error) => void;
|
|
const promise = new Promise<void>((yes, no) => {
|
|
resolve = yes;
|
|
reject = no;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
const bridges: Bridge[] = [];
|
|
afterEach(async () => {
|
|
await Promise.all(bridges.splice(0).map((bridge) => bridge.dispose()));
|
|
vi.restoreAllMocks();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
function friend(uin: number, name = 'Roster'): OidbFriend {
|
|
return {
|
|
uin,
|
|
uid: `u_${uin}`,
|
|
customGroup: 7,
|
|
additional: [
|
|
{
|
|
type: 1,
|
|
layer1: {
|
|
properties: [
|
|
{ code: 20002, value: Buffer.from(name) },
|
|
{ code: 102, value: Buffer.from('') },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function setup(roster: () => OidbFriend[], load: (uin: number) => Promise<void> = async () => {}) {
|
|
const bridge = new Bridge(IdentityService.memory('90001'));
|
|
bridges.push(bridge);
|
|
const profileCalls: number[] = [];
|
|
let active = 0;
|
|
let peak = 0;
|
|
const send = vi.spyOn(bridge, 'sendRawPacket').mockImplementation(async (cmd, payload) => {
|
|
let responseData: Uint8Array;
|
|
|
|
if (cmd === 'OidbSvcTrpcTcp.0xfd4_1') {
|
|
const friends = roster();
|
|
responseData = protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFD4_1Response>>({
|
|
body: {
|
|
friends,
|
|
categories: [
|
|
{ categoryId: 7, categoryName: 'Friends', memberCount: friends.length },
|
|
],
|
|
},
|
|
});
|
|
} else if (cmd === 'OidbSvcTrpcTcp.0xfe1_2') {
|
|
const uin = protobuf_decode<OidbBase<OidbUserInfoRequest>>(payload).body!.uin!;
|
|
profileCalls.push(uin);
|
|
peak = Math.max(peak, ++active);
|
|
|
|
try {
|
|
await load(uin);
|
|
} finally {
|
|
active--;
|
|
}
|
|
|
|
responseData = protobuf_encode<OidbBase<OidbUserInfoResponse>>({
|
|
body: {
|
|
body: {
|
|
uin,
|
|
uid: `u_${uin}`,
|
|
properties: {
|
|
bytesProperties: [
|
|
{ code: 102, value: Buffer.from('Profile sign') },
|
|
{ code: 20020, value: Buffer.from('City') },
|
|
],
|
|
numberProperties: [
|
|
{ number1: 20037, number2: 0 },
|
|
{ number1: 41756, number2: 0 },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
} else {
|
|
throw new Error(`unexpected request ${cmd}`);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
gotResponse: true,
|
|
errorCode: 0,
|
|
errorMessage: '',
|
|
responseData: Buffer.from(responseData),
|
|
};
|
|
});
|
|
return {
|
|
bridge,
|
|
api: bridge.apis.contacts,
|
|
send,
|
|
profileCalls,
|
|
peak: () => peak,
|
|
active: () => active,
|
|
};
|
|
}
|
|
|
|
describe('explicit friend profile enrichment', () => {
|
|
it('releases profile cache timers when its Bridge is disposed', async () => {
|
|
vi.useFakeTimers();
|
|
const { api, bridge, profileCalls } = setup(() => [friend(10001)]);
|
|
await api.fetchFriendList({ withProfile: true });
|
|
expect(profileCalls).toEqual([10001]);
|
|
expect(vi.getTimerCount()).toBe(1);
|
|
|
|
await bridge.dispose();
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
it('does not start profile cache loads when a roster response arrives after disposal', async () => {
|
|
vi.useFakeTimers();
|
|
const { api, bridge, send, profileCalls } = setup(() => [friend(10001)]);
|
|
const response = await send.getMockImplementation()!(
|
|
'OidbSvcTrpcTcp.0xfd4_1',
|
|
new Uint8Array(),
|
|
);
|
|
let resolve!: (value: typeof response) => void;
|
|
send.mockReturnValueOnce(
|
|
new Promise((yes) => {
|
|
resolve = yes;
|
|
}),
|
|
);
|
|
const pending = api.fetchFriendList({ withProfile: true });
|
|
const checked = expect(pending).rejects.toThrow('friend profile enrichment failed');
|
|
await bridge.dispose();
|
|
|
|
resolve(response);
|
|
await checked;
|
|
expect(profileCalls).toEqual([]);
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
it('does not fan out on a cold 160-friend ordinary or categorized list', async () => {
|
|
const { api, profileCalls, send } = setup(() =>
|
|
Array.from({ length: 160 }, (_, i) => friend(10000 + i)),
|
|
);
|
|
expect(await api.fetchFriendList()).toHaveLength(160);
|
|
expect((await api.fetchFriendCategories())[0]?.friends).toHaveLength(160);
|
|
expect(profileCalls).toEqual([]);
|
|
expect(send).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('shares the cache across list forms, preserves roster values and isolates accounts', async () => {
|
|
let now = Date.now();
|
|
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
const one = setup(() => [friend(10001)]);
|
|
const first = await one.api.fetchFriendList({ withProfile: true });
|
|
expect(first[0]).toMatchObject({
|
|
nickname: 'Roster',
|
|
profile: { sign: '', age: 0, vipFlag: false, city: 'City' },
|
|
});
|
|
first[0]!.profile!.city = 'caller mutation';
|
|
const grouped = await one.api.fetchFriendCategories({ withProfile: true });
|
|
expect(grouped[0]?.friends[0]?.profile?.city).toBe('City');
|
|
expect(one.profileCalls).toEqual([10001]);
|
|
const two = setup(() => [friend(10001)]);
|
|
await two.api.fetchFriendList({ withProfile: true });
|
|
expect(two.profileCalls).toEqual([10001]);
|
|
now += 300_001;
|
|
await one.api.fetchFriendList({ withProfile: true });
|
|
expect(one.profileCalls).toEqual([10001, 10001]);
|
|
});
|
|
|
|
it('deduplicates overlapping lists and caps total concurrent queries at four', async () => {
|
|
const gates = new Map<number, ReturnType<typeof deferred>>();
|
|
let page = 0;
|
|
const { api, profileCalls, peak } = setup(
|
|
() => (page++ === 0 ? [1, 2, 3, 4] : [3, 4, 5, 6]).map((id) => friend(10000 + id)),
|
|
(uin) => {
|
|
const gate = deferred();
|
|
gates.set(uin, gate);
|
|
return gate.promise;
|
|
},
|
|
);
|
|
const first = api.fetchFriendList({ withProfile: true });
|
|
const second = api.fetchFriendCategories({ withProfile: true });
|
|
await vi.waitFor(() => expect(profileCalls).toHaveLength(4));
|
|
|
|
for (const gate of gates.values()) {
|
|
gate.resolve();
|
|
}
|
|
|
|
await vi.waitFor(() => expect(profileCalls).toHaveLength(6));
|
|
|
|
for (const gate of gates.values()) {
|
|
gate.resolve();
|
|
}
|
|
|
|
await Promise.all([first, second]);
|
|
expect(new Set(profileCalls).size).toBe(6);
|
|
expect(peak()).toBe(4);
|
|
});
|
|
|
|
it('drains a failed batch, surfaces the failure and retries only on a later request', async () => {
|
|
const gates = new Map<number, ReturnType<typeof deferred>>();
|
|
let blocking = true;
|
|
const { api, bridge, profileCalls, active } = setup(
|
|
() => [1, 2, 3, 4, 5].map((id) => friend(10000 + id)),
|
|
(uin) => {
|
|
if (!blocking) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
const gate = deferred();
|
|
gates.set(uin, gate);
|
|
return gate.promise;
|
|
},
|
|
);
|
|
let finished = false;
|
|
const result = api.fetchFriendList({ withProfile: true });
|
|
const checked = expect(result).rejects.toThrow('friend profile enrichment failed');
|
|
void result.then(
|
|
() => {
|
|
finished = true;
|
|
},
|
|
() => {
|
|
finished = true;
|
|
},
|
|
);
|
|
await vi.waitFor(() => expect(profileCalls).toHaveLength(4));
|
|
gates.get(10001)!.reject(new Error('synthetic timeout'));
|
|
await Promise.resolve();
|
|
expect(finished).toBe(false);
|
|
|
|
for (const [uin, gate] of gates) {
|
|
if (uin !== 10001) {
|
|
gate.resolve();
|
|
}
|
|
}
|
|
|
|
await checked;
|
|
expect(active()).toBe(0);
|
|
expect(profileCalls).toHaveLength(4);
|
|
expect(bridge.identity.friends).toEqual([]);
|
|
blocking = false;
|
|
await api.fetchFriendList({ withProfile: true });
|
|
expect(profileCalls).toEqual([10001, 10002, 10003, 10004, 10001, 10005]);
|
|
});
|
|
|
|
it('does not let a slow enriched snapshot replace a newer ordinary roster', async () => {
|
|
const gate = deferred();
|
|
let page = 0;
|
|
const { api, bridge, profileCalls } = setup(
|
|
() => [friend(10001, page++ === 0 ? 'Old' : 'New')],
|
|
() => gate.promise,
|
|
);
|
|
const old = api.fetchFriendList({ withProfile: true });
|
|
await vi.waitFor(() => expect(profileCalls).toHaveLength(1));
|
|
await api.fetchFriendList();
|
|
gate.resolve();
|
|
expect((await old)[0]?.nickname).toBe('Old');
|
|
expect(bridge.identity.findFriend(10001)?.nickname).toBe('New');
|
|
});
|
|
});
|