Files
SnowLuma/packages/core/tests/identity-service.test.ts
TanTan fa9c73363d
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
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

1542 lines
58 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs';
import { DatabaseSync } from 'node:sqlite';
import path from 'path';
import { subscribeLogs } from '@snowluma/common/logger';
import { SqliteDatabase } from '@snowluma/sqlite';
import { IdentityService } from '@snowluma/protocol/identity-service';
import type { GroupMemberInfo, QQGroupInfo, UserProfileInfo } from '@snowluma/protocol/qq-info';
const SELF_UIN = '10001';
const GROUP_ID = 123456789;
const dbs: string[] = [];
function tempDbPath(label: string): string {
const dbPath = path.join(
'data',
'test',
`snowluma-identity-${label}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
);
dbs.push(dbPath);
return dbPath;
}
function cleanupDb(dbPath: string): void {
for (const ext of ['', '-wal', '-shm']) {
try {
fs.unlinkSync(dbPath + ext);
} catch {
/* ignore */
}
}
}
async function holdWriteLock(dbPath: string): Promise<() => Promise<void>> {
const locker = new SqliteDatabase(dbPath, { busyTimeout: 0 });
await locker.exec('BEGIN IMMEDIATE');
let released = false;
return async () => {
if (released) {
return;
}
released = true;
await locker.exec('ROLLBACK');
await locker.close();
};
}
function makeGroup(): QQGroupInfo {
return {
groupId: GROUP_ID,
groupName: 'group',
remark: '',
memberCount: 0,
memberMax: 500,
members: new Map(),
};
}
function makeMember(uin: number, uid: string, card = '', isRobot = false): GroupMemberInfo {
return {
uin,
uid,
nickname: `nick-${uin}`,
card,
isRobot,
role: 'member',
level: 1,
title: '',
joinTime: 10,
lastSentTime: 20,
shutUpTime: 0,
};
}
afterEach(() => {
vi.useRealTimers();
for (const dbPath of dbs.splice(0)) {
cleanupDb(dbPath);
}
});
describe('IdentityService', () => {
it('replays observations accepted during loading and drains them when closed before ready', async () => {
const dbPath = tempDbPath('async-startup');
const previous = new IdentityService(SELF_UIN, dbPath);
previous.rememberFriends([{ uin: 22222, uid: 'u_old', nickname: 'old', remark: '' }]);
await previous.close();
let release!: () => void;
const loading = new Promise<void>((resolve) => {
release = resolve;
});
const identity = new IdentityService(SELF_UIN, dbPath, loading);
const friends = [{ uin: 33333, uid: 'u_new', nickname: 'accepted', remark: '' }];
identity.rememberFriends(friends);
friends[0]!.nickname = 'mutated by caller';
expect(identity.findFriend(33333)?.nickname).toBe('accepted');
const closing = identity.close();
expect(() => identity.rememberFriends([])).toThrow(/closed/);
release();
await closing;
const reopened = new IdentityService(SELF_UIN, dbPath);
try {
await reopened.initialization;
expect(reopened.findFriend(22222)).toBeNull();
expect(reopened.findFriend(33333)?.nickname).toBe('accepted');
} finally {
await reopened.close();
}
});
it('persists friends, groups, and active group members', async () => {
const dbPath = tempDbPath('persist');
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
identity.rememberFriends([
{ uin: 22222, uid: 'u_friend', nickname: 'friend', remark: 'remark' },
]);
identity.rememberGroups([makeGroup()]);
identity.rememberGroupMembers(GROUP_ID, [makeMember(33333, 'u_member', 'card')]);
await identity.close();
}
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
expect(identity.findFriend(22222)?.uid).toBe('u_friend');
expect(identity.findGroup(GROUP_ID)?.groupName).toBe('group');
expect(identity.findGroupMember(GROUP_ID, 33333)?.card).toBe('card');
expect(identity.findUidByUin(33333, GROUP_ID)).toBe('u_member');
expect(identity.findUinByUid('u_member', GROUP_ID)).toBe(33333);
await identity.close();
}
});
it('retains legacy member identities and refreshes only damaged cards across restarts', async () => {
const dbPath = tempDbPath('legacy-card');
const member = makeMember(33333, 'u_member', 'placeholder');
const other = makeMember(44444, 'u_other', 'ordinary');
const seed = new IdentityService(SELF_UIN, dbPath);
seed.rememberGroups([makeGroup()]);
seed.rememberGroupMembers(GROUP_ID, [member, other]);
await seed.close();
// Persisted bytes from #646 after the old text normalization stripped the envelope prefix.
const damaged = Buffer.concat([
Buffer.from('1209', 'hex'),
Buffer.from('三字名'),
Buffer.from('0a090a0724c3bfc48011221000', 'hex'),
]);
const sql = new DatabaseSync(dbPath);
try {
sql.prepare('UPDATE group_members SET card = CAST(? AS TEXT) WHERE uin = ?').run(
damaged,
member.uin,
);
} finally {
sql.close();
}
const stale = new IdentityService(SELF_UIN, dbPath);
try {
await stale.initialization;
const cached = stale.findGroupMember(GROUP_ID, member.uin)!;
expect(cached).toMatchObject({ card: undefined, uid: member.uid, isRobot: false });
expect(stale.findGroupMember(GROUP_ID, other.uin)?.card).toBe('ordinary');
stale.updateGroupMember(GROUP_ID, { ...cached, role: 'admin' });
} finally {
await stale.close();
}
const refreshed = new IdentityService(SELF_UIN, dbPath);
try {
await refreshed.initialization;
expect(refreshed.findGroupMember(GROUP_ID, member.uin)).toMatchObject({
card: undefined,
role: 'admin',
});
refreshed.rememberGroupMembers(GROUP_ID, [
{ ...member, role: 'admin', card: ' 三字名 ' },
other,
]);
} finally {
await refreshed.close();
}
const restored = new IdentityService(SELF_UIN, dbPath);
try {
await restored.initialization;
expect(restored.findGroupMember(GROUP_ID, member.uin)).toMatchObject({
card: ' 三字名 ',
role: 'admin',
});
expect(restored.findGroupMember(GROUP_ID, other.uin)?.card).toBe('ordinary');
} finally {
await restored.close();
}
});
it('persists group member robot classification across restarts', async () => {
const dbPath = tempDbPath('persist-robot');
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
identity.rememberGroups([makeGroup()]);
identity.rememberGroupMembers(GROUP_ID, [makeMember(33333, 'u_robot', '', true)]);
await identity.close();
}
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
expect(identity.findGroupMember(GROUP_ID, 33333)?.isRobot).toBe(true);
await identity.close();
}
});
it('migrates legacy member caches without inventing a robot classification', async () => {
const dbPath = tempDbPath('migrate-robot');
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const legacy = new DatabaseSync(dbPath);
legacy.exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT UNIQUE,
uin INTEGER UNIQUE,
nickname TEXT NOT NULL DEFAULT '',
remark TEXT NOT NULL DEFAULT '',
is_friend INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL
);
CREATE TABLE groups (
group_id INTEGER PRIMARY KEY,
group_name TEXT NOT NULL DEFAULT '',
remark TEXT NOT NULL DEFAULT '',
member_count INTEGER NOT NULL DEFAULT 0,
member_max INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1,
updated_at INTEGER NOT NULL
);
CREATE TABLE group_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL,
uid TEXT,
uin INTEGER,
nickname TEXT NOT NULL DEFAULT '',
card TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'member',
level INTEGER NOT NULL DEFAULT 0,
title TEXT NOT NULL DEFAULT '',
join_time INTEGER NOT NULL DEFAULT 0,
last_sent_time INTEGER NOT NULL DEFAULT 0,
shut_up_time INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1,
updated_at INTEGER NOT NULL,
UNIQUE(group_id, uid),
UNIQUE(group_id, uin)
);
INSERT INTO groups (group_id, group_name, updated_at)
VALUES (${GROUP_ID}, 'legacy-group', 1);
INSERT INTO group_members (group_id, uid, uin, nickname, updated_at)
VALUES (${GROUP_ID}, 'u_legacy', 33333, 'legacy-member', 1);
`);
legacy.close();
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
expect(identity.findGroupMember(GROUP_ID, 33333)?.isRobot).toBeUndefined();
await identity.close();
const migrated = new DatabaseSync(dbPath);
const columns = migrated.prepare('PRAGMA table_info(group_members)').all() as Array<{
name: string;
}>;
expect(columns.map((column) => column.name)).toContain('is_robot');
migrated.close();
});
it('keeps updateGroupMember changes after reopening the persistent cache', async () => {
const dbPath = tempDbPath('update-member');
const member = makeMember(33333, 'u_member', 'old-card');
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
identity.rememberGroups([makeGroup()]);
identity.rememberGroupMembers(GROUP_ID, [member]);
identity.updateGroupMember(GROUP_ID, { ...member, card: 'new-card' });
expect(identity.findGroupMember(GROUP_ID, member.uin)?.card).toBe('new-card');
await identity.close();
}
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
expect(identity.findGroupMember(GROUP_ID, member.uin)?.card).toBe('new-card');
await identity.close();
}
});
it('persists an explicitly cleared member card instead of restoring the old value', async () => {
const dbPath = tempDbPath('clear-member-card');
const member = makeMember(33333, 'u_member', 'old-card');
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
identity.rememberGroups([makeGroup()]);
identity.rememberGroupMembers(GROUP_ID, [member]);
identity.updateGroupMember(GROUP_ID, { ...member, card: '' });
expect(identity.findGroupMember(GROUP_ID, member.uin)?.card).toBe('');
await identity.close();
}
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
expect(identity.findGroupMember(GROUP_ID, member.uin)?.card).toBe('');
await identity.close();
}
});
it('keeps friend remark updates after reopening the persistent cache', async () => {
const dbPath = tempDbPath('update-friend-remark');
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
identity.rememberFriends([
{
uin: 22222,
uid: 'u_friend',
nickname: 'friend',
remark: 'old-remark',
},
]);
expect(identity.updateFriendRemark('u_friend', 22222, 'new-remark')).toBe(true);
expect(identity.findFriend(22222)?.remark).toBe('new-remark');
await identity.close();
}
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
expect(identity.findFriend(22222)?.remark).toBe('new-remark');
await identity.close();
}
});
it('keeps exact group remark updates after reopening the persistent cache', async () => {
const dbPath = tempDbPath('update-group-remark');
const remark = ' project group ';
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
identity.rememberGroups([{ ...makeGroup(), remark: 'old-remark' }]);
expect(identity.updateGroupRemark(GROUP_ID, remark)).toBe(true);
expect(identity.findGroup(GROUP_ID)?.remark).toBe(remark);
await identity.close();
}
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
expect(identity.findGroup(GROUP_ID)?.remark).toBe(remark);
await identity.close();
}
});
it('does not invent a group while applying a remark update', async () => {
const identity = IdentityService.memory(SELF_UIN);
identity.rememberGroups([makeGroup()]);
expect(identity.updateGroupRemark(987654321, 'unknown')).toBe(false);
expect(identity.groups).toHaveLength(1);
expect(identity.findGroup(GROUP_ID)?.remark).toBe('');
expect(identity.findGroup(987654321)).toBeNull();
await identity.close();
});
it.each([
['empty', ''],
['whitespace-only', ' '],
['surrounding-whitespace', ' padded remark '],
])('preserves %s friend remarks exactly after reopening', async (_label, remark) => {
const dbPath = tempDbPath('exact-friend-remark');
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
identity.rememberFriends([
{
uin: 22222,
uid: 'u_friend',
nickname: 'friend',
remark: 'old-remark',
},
]);
expect(identity.updateFriendRemark('u_friend', 22222, remark)).toBe(true);
expect(identity.findFriend(22222)?.remark).toBe(remark);
await identity.close();
}
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
expect(identity.findFriend(22222)?.remark).toBe(remark);
await identity.close();
}
});
it('rejects conflicting friend identities before mutating the roster', () => {
const identity = IdentityService.memory(SELF_UIN);
identity.rememberFriends([
{ uin: 22222, uid: 'u_first', nickname: 'first', remark: 'first-remark' },
{ uin: 33333, uid: 'u_second', nickname: 'second', remark: 'second-remark' },
]);
expect(() => identity.updateFriendRemark('u_first', 33333, 'wrong')).toThrow(
/identity mismatch/,
);
expect(identity.findFriend(22222)?.remark).toBe('first-remark');
expect(identity.findFriend(33333)?.remark).toBe('second-remark');
});
it('keeps a failed observation in memory, reports degraded, and retries to healthy', async () => {
vi.useFakeTimers();
const dbPath = tempDbPath('retry-recovery');
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
const releaseLock = await holdWriteLock(dbPath);
let closed = false;
try {
identity.rememberFriends([
{
uin: 22222,
uid: 'u_retry',
nickname: 'retry-friend',
remark: '',
},
]);
expect(identity.findFriend(22222)?.uid).toBe('u_retry');
await expect(identity.flush()).rejects.toThrow(/locked/i);
expect(identity.persistenceStatus).toMatchObject({
state: 'degraded',
pendingWrites: 1,
lastFailedLabel: 'friends',
retryAttempt: 1,
});
expect(identity.persistenceStatus.lastError).toMatch(/locked/i);
expect(identity.persistenceStatus.nextRetryAt! - Date.now()).toBe(100);
await vi.advanceTimersByTimeAsync(100);
await expect(identity.flush()).rejects.toThrow(/locked/i);
expect(identity.persistenceStatus).toMatchObject({
state: 'degraded',
pendingWrites: 1,
retryAttempt: 2,
});
expect(identity.persistenceStatus.nextRetryAt! - Date.now()).toBe(500);
await releaseLock();
await vi.advanceTimersByTimeAsync(500);
await identity.flush();
expect(identity.persistenceStatus).toMatchObject({
state: 'healthy',
pendingWrites: 0,
retryAttempt: 0,
nextRetryAt: null,
});
await identity.close();
closed = true;
const reopened = new IdentityService(SELF_UIN, dbPath);
await reopened.initialization;
expect(reopened.findFriend(22222)?.uid).toBe('u_retry');
await reopened.close();
} finally {
await releaseLock();
if (!closed) {
await identity.close();
}
}
});
it('replays pending observations in their original order', async () => {
vi.useFakeTimers();
const dbPath = tempDbPath('retry-order');
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
const releaseLock = await holdWriteLock(dbPath);
let closed = false;
try {
identity.rememberFriends([
{
uin: 11111,
uid: 'u_first',
nickname: 'first',
remark: '',
},
]);
identity.rememberFriends([
{
uin: 22222,
uid: 'u_second',
nickname: 'second',
remark: '',
},
]);
expect(identity.persistenceStatus.pendingWrites).toBe(2);
expect(identity.findFriend(11111)).toBeNull();
expect(identity.findFriend(22222)?.uid).toBe('u_second');
await releaseLock();
await vi.runOnlyPendingTimersAsync();
await identity.close();
closed = true;
const reopened = new IdentityService(SELF_UIN, dbPath);
await reopened.initialization;
expect(reopened.findFriend(11111)).toBeNull();
expect(reopened.findFriend(22222)?.uid).toBe('u_second');
await reopened.close();
} finally {
await releaseLock();
if (!closed) {
await identity.close();
}
}
});
it('replays the snapshot accepted at observation time when caller input later mutates', async () => {
vi.useFakeTimers();
const dbPath = tempDbPath('retry-snapshot');
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
const member = makeMember(33333, 'u_member', 'before');
identity.rememberGroups([makeGroup()]);
identity.rememberGroupMembers(GROUP_ID, [member]);
await identity.flush();
const releaseLock = await holdWriteLock(dbPath);
const observed = { ...member, card: 'accepted' };
try {
identity.updateGroupMember(GROUP_ID, observed);
observed.card = 'caller-mutated';
expect(identity.findGroupMember(GROUP_ID, member.uin)?.card).toBe('accepted');
await releaseLock();
await vi.runOnlyPendingTimersAsync();
await identity.close();
const reopened = new IdentityService(SELF_UIN, dbPath);
await reopened.initialization;
expect(reopened.findGroupMember(GROUP_ID, member.uin)?.card).toBe('accepted');
await reopened.close();
} finally {
await releaseLock();
await identity.close();
}
});
it('does not let a group refresh restore stale SQLite members over a pending member update', async () => {
vi.useFakeTimers();
const dbPath = tempDbPath('pending-member-group-refresh');
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
const member = makeMember(33333, 'u_member', 'persisted-card');
identity.rememberGroups([makeGroup()]);
identity.rememberGroupMembers(GROUP_ID, [member]);
await identity.flush();
const releaseLock = await holdWriteLock(dbPath);
try {
identity.updateGroupMember(GROUP_ID, { ...member, card: 'pending-card' });
identity.rememberGroups([{ ...makeGroup(), groupName: 'refreshed-group' }]);
expect(identity.findGroup(GROUP_ID)?.groupName).toBe('refreshed-group');
expect(identity.findGroupMember(GROUP_ID, member.uin)?.card).toBe('pending-card');
await releaseLock();
await vi.runOnlyPendingTimersAsync();
await identity.close();
const reopened = new IdentityService(SELF_UIN, dbPath);
await reopened.initialization;
expect(reopened.findGroup(GROUP_ID)?.groupName).toBe('refreshed-group');
expect(reopened.findGroupMember(GROUP_ID, member.uin)?.card).toBe('pending-card');
await reopened.close();
} finally {
await releaseLock();
await identity.close();
}
});
it('rejects stale SQLite fallbacks after pending UID and UIN rebinds', async () => {
vi.useFakeTimers();
const dbPath = tempDbPath('pending-rebind');
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
identity.rememberRequestIdentity({ uid: 'u_old_for_uin', uin: 22001 });
identity.rememberRequestIdentity({ uid: 'u_stable', uin: 22002 });
await identity.flush();
const releaseLock = await holdWriteLock(dbPath);
try {
identity.rememberRequestIdentity({ uid: 'u_new_for_uin', uin: 22001 });
identity.rememberRequestIdentity({ uid: 'u_stable', uin: 22999 });
expect(identity.findUidByUin(22001)).toBe('u_new_for_uin');
expect(identity.findUinByUid('u_stable')).toBe(22999);
expect(identity.findUinByUid('u_old_for_uin')).toBeNull();
expect(identity.findUidByUin(22002)).toBeNull();
await releaseLock();
await vi.runOnlyPendingTimersAsync();
await identity.close();
const reopened = new IdentityService(SELF_UIN, dbPath);
await reopened.initialization;
expect(reopened.findUidByUin(22001)).toBe('u_new_for_uin');
expect(reopened.findUinByUid('u_stable')).toBe(22999);
await reopened.close();
} finally {
await releaseLock();
await identity.close();
}
});
it('fails fast before accepting an observation when the retry queue is full', async () => {
vi.useFakeTimers();
const dbPath = tempDbPath('retry-capacity');
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
const releaseLock = await holdWriteLock(dbPath);
try {
const capacity = identity.persistenceStatus.queueCapacity;
expect(capacity).toBeGreaterThan(0);
for (let i = 0; i < capacity; i += 1) {
identity.rememberRequestIdentity({
uid: `u_pending_${i}`,
uin: 100_000 + i,
source: 'capacity-test',
});
}
expect(identity.persistenceStatus.pendingWrites).toBe(capacity);
expect(() =>
identity.rememberRequestIdentity({
uid: 'u_overflow',
uin: 999_999,
source: 'capacity-test',
}),
).toThrow(/persistence retry queue full/i);
expect(identity.findUinByUid('u_overflow')).toBeNull();
expect(identity.persistenceStatus).toMatchObject({
state: 'degraded',
pendingWrites: capacity,
lastFailedLabel: 'request identity',
});
expect(identity.persistenceStatus.lastError).toMatch(/queue full/i);
} finally {
await releaseLock();
await vi.runOnlyPendingTimersAsync();
await identity.close();
}
});
it('suspends SQLite persistence after five failed retries and only recovers after restart', async () => {
vi.useFakeTimers();
const dbPath = tempDbPath('retry-exhausted');
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
const releaseLock = await holdWriteLock(dbPath);
const seen: Array<{ level: string; message: string }> = [];
const unsubscribe = subscribeLogs((entry) => {
if (entry.scope === 'Identity' && entry.uin === Number(SELF_UIN)) {
seen.push(entry);
}
});
try {
identity.rememberFriends([
{
uin: 22222,
uid: 'u_abandoned_friend',
nickname: 'abandoned',
remark: '',
},
]);
identity.rememberRequestIdentity({ uid: 'u_abandoned_request', uin: 33333 });
await expect(identity.flush()).rejects.toThrow(/locked/i);
for (const delay of [100, 500, 2_000, 10_000, 30_000]) {
await vi.advanceTimersByTimeAsync(delay);
await identity.flush().catch(() => undefined);
}
expect(identity.persistenceStatus).toMatchObject({
state: 'degraded',
suspended: true,
pendingWrites: 0,
retryAttempt: 6,
nextRetryAt: null,
abandonedWrites: 2,
skippedWrites: 0,
});
expect(vi.getTimerCount()).toBe(0);
expect(seen).toContainEqual(
expect.objectContaining({
level: 'error',
message: expect.stringMatching(
/persistence suspended.*uin=10001.*label=friends.*pending=2.*retry=6.*abandoned=2/i,
),
}),
);
const suspendedFailureAt = identity.persistenceStatus.lastFailureAt;
await vi.advanceTimersByTimeAsync(1);
expect(() =>
identity.rememberRequestIdentity({
uid: 'u_skipped_after_suspend',
uin: 44444,
}),
).not.toThrow();
expect(identity.findUinByUid('u_skipped_after_suspend')).toBe(44444);
expect(identity.persistenceStatus).toMatchObject({
state: 'degraded',
suspended: true,
pendingWrites: 0,
abandonedWrites: 3,
skippedWrites: 1,
lastFailedLabel: 'friends',
lastError: expect.stringMatching(/locked/i),
lastFailureAt: suspendedFailureAt,
});
const skipLogsBeforeBurst = seen.filter((entry) =>
/persistence write skipped/.test(entry.message),
).length;
expect(skipLogsBeforeBurst).toBe(1);
for (let i = 0; i < 50; i += 1) {
identity.rememberRequestIdentity({
uid: `u_skip_burst_${i}`,
uin: 500_000 + i,
});
}
expect(
seen.filter((entry) => /persistence write skipped/.test(entry.message)),
).toHaveLength(1);
await vi.advanceTimersByTimeAsync(60_000);
identity.rememberRequestIdentity({ uid: 'u_skip_after_minute', uin: 600_000 });
const skipLogs = seen.filter((entry) =>
/persistence write skipped/.test(entry.message),
);
expect(skipLogs).toHaveLength(2);
expect(skipLogs[1]?.message).toMatch(/suppressed=50/);
await releaseLock();
await identity.close();
const reopened = new IdentityService(SELF_UIN, dbPath);
await reopened.initialization;
expect(reopened.persistenceStatus).toMatchObject({
state: 'healthy',
suspended: false,
abandonedWrites: 0,
skippedWrites: 0,
});
expect(reopened.findUinByUid('u_abandoned_request')).toBeNull();
expect(reopened.findUinByUid('u_skipped_after_suspend')).toBeNull();
reopened.rememberRequestIdentity({ uid: 'u_after_restart', uin: 55555 });
await reopened.close();
const persisted = new IdentityService(SELF_UIN, dbPath);
await persisted.initialization;
expect(persisted.findUinByUid('u_after_restart')).toBe(55555);
await persisted.close();
} finally {
unsubscribe();
await releaseLock();
await identity.close();
}
});
it('flushes pending writes during asynchronous close when SQLite has recovered', async () => {
vi.useFakeTimers();
const dbPath = tempDbPath('close-flush');
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
const releaseLock = await holdWriteLock(dbPath);
identity.rememberFriends([
{
uin: 22222,
uid: 'u_close_flush',
nickname: 'close-flush',
remark: '',
},
]);
expect(identity.persistenceStatus.pendingWrites).toBe(1);
await releaseLock();
await identity.close();
expect(identity.persistenceStatus).toMatchObject({
state: 'closed',
pendingWrites: 0,
abandonedWrites: 0,
nextRetryAt: null,
});
const reopened = new IdentityService(SELF_UIN, dbPath);
await reopened.initialization;
expect(reopened.findFriend(22222)?.uid).toBe('u_close_flush');
await reopened.close();
});
it('warns and releases pending retry work when close cannot flush SQLite', async () => {
vi.useFakeTimers();
const dbPath = tempDbPath('close-pending');
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
const releaseLock = await holdWriteLock(dbPath);
const seen: Array<{ level: string; message: string }> = [];
const unsubscribe = subscribeLogs((entry) => {
if (entry.scope === 'Identity' && entry.uin === Number(SELF_UIN)) {
seen.push(entry);
}
});
try {
identity.rememberFriends([
{
uin: 22222,
uid: 'u_close_pending',
nickname: 'close-pending',
remark: '',
},
]);
expect(identity.persistenceStatus.pendingWrites).toBe(1);
await identity.close();
expect(identity.persistenceStatus).toMatchObject({
state: 'closed',
pendingWrites: 0,
abandonedWrites: 1,
lastFailedLabel: 'friends',
nextRetryAt: null,
});
expect(vi.getTimerCount()).toBe(0);
expect(seen).toContainEqual(
expect.objectContaining({
level: 'warn',
message: expect.stringMatching(
/close with pending writes.*uin=10001.*label=friends.*pending=1/i,
),
}),
);
} finally {
unsubscribe();
await releaseLock();
await identity.close();
}
});
it('keeps shutdown terminal and reports pending writes when SQLite cleanup needs a retry', async () => {
vi.useFakeTimers();
const dbPath = tempDbPath('close-failure');
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
const releaseLock = await holdWriteLock(dbPath);
const database = (identity as unknown as { db: SqliteDatabase }).db;
const closeSpy = vi.spyOn(database, 'close').mockImplementationOnce(() => {
throw new Error('injected close failure');
});
try {
identity.rememberFriends([
{
uin: 22222,
uid: 'u_close_retry_friend',
nickname: 'close-retry',
remark: '',
},
]);
expect(identity.persistenceStatus.pendingWrites).toBe(1);
await expect(identity.flush()).rejects.toThrow(/locked/i);
await expect(identity.close()).rejects.toThrow(/injected close failure/);
expect(identity.persistenceStatus).toMatchObject({
state: 'degraded',
pendingWrites: 1,
abandonedWrites: 0,
retryAttempt: 2,
});
expect(vi.getTimerCount()).toBe(0);
expect(identity.persistenceStatus.nextRetryAt).toBeNull();
await expect(identity.flush()).rejects.toThrow(/closed/);
expect(() =>
identity.rememberRequestIdentity({
uid: 'u_after_close_failure',
uin: 33333,
}),
).toThrow(/closed/);
expect(identity.persistenceStatus.pendingWrites).toBe(1);
closeSpy.mockRestore();
await releaseLock();
await identity.close();
expect(identity.persistenceStatus).toMatchObject({
state: 'closed',
pendingWrites: 0,
abandonedWrites: 1,
});
const reopened = new IdentityService(SELF_UIN, dbPath);
await reopened.initialization;
expect(reopened.findFriend(22222)).toBeNull();
expect(reopened.findUinByUid('u_after_close_failure')).toBeNull();
await reopened.close();
} finally {
closeSpy.mockRestore();
await releaseLock();
await identity.close();
}
});
it('fails fast on every observation entry point after close', async () => {
const identity = IdentityService.memory(SELF_UIN);
const member = makeMember(33333, 'u_member', 'before-close');
identity.rememberGroups([makeGroup()]);
identity.rememberGroupMembers(GROUP_ID, [member]);
await identity.close();
const profile: UserProfileInfo = {
uin: 44444,
uid: 'u_profile',
nickname: 'profile',
remark: '',
qid: '',
sex: 'unknown',
age: 0,
sign: '',
avatar: '',
level: 0,
qidianMasterFlag: 0,
qidianCrewFlag: 0,
qidianCrewFlag2: 0,
};
const request = {
groupId: GROUP_ID,
groupName: 'group',
targetUid: 'u_target',
targetUin: 55555,
targetName: 'target',
invitorUid: 'u_invitor',
invitorUin: 66666,
invitorName: 'invitor',
operatorUid: 'u_operator',
operatorUin: 77777,
operatorName: 'operator',
sequence: 1,
state: 0,
eventType: 1,
comment: '',
filtered: false,
};
const observations: Array<[string, () => void]> = [
[
'nickname',
() => {
identity.nickname = 'after-close';
},
],
['self profile', () => identity.setSelfProfile(profile)],
[
'group member update',
() => identity.updateGroupMember(GROUP_ID, { ...member, card: 'after-close' }),
],
['group remark update', () => identity.updateGroupRemark(GROUP_ID, 'after-close')],
[
'friends',
() =>
identity.rememberFriends([
{ uin: 22222, uid: 'u_friend', nickname: '', remark: '' },
]),
],
['groups', () => identity.rememberGroups([])],
['forget group', () => identity.forgetGroup(GROUP_ID)],
['group members', () => identity.rememberGroupMembers(GROUP_ID, [])],
['user profile', () => identity.rememberUserProfile(profile)],
['group requests', () => identity.rememberGroupRequests([request])],
[
'request identity',
() => identity.rememberRequestIdentity({ uid: 'u_request', uin: 88888 }),
],
[
'group member identity',
() => identity.rememberGroupMemberIdentity(GROUP_ID, { uid: 'u_new', uin: 99999 }),
],
[
'group member joined',
() => identity.rememberGroupMemberJoined(GROUP_ID, { uid: 'u_new', uin: 99999 }),
],
[
'group member inactive',
() =>
identity.markGroupMemberInactive(GROUP_ID, {
uid: member.uid,
uin: member.uin,
}),
],
];
for (const [label, observe] of observations) {
expect(observe, label).toThrow(/IdentityService is closed/);
}
expect(identity.nickname).toBe('');
expect(identity.findGroup(GROUP_ID)).not.toBeNull();
expect(identity.findGroupMember(GROUP_ID, member.uin)?.card).toBe('before-close');
expect(identity.findFriend(22222)).toBeNull();
expect(identity.findUinByUid('u_request')).toBeNull();
});
it('does not treat identity-only sightings as live group members', async () => {
const identity = IdentityService.memory(SELF_UIN);
identity.rememberGroups([makeGroup()]);
identity.rememberGroupMemberIdentity(GROUP_ID, { uid: 'u_seen', uin: 88888 });
expect(identity.findGroupMember(GROUP_ID, 88888)).toBeNull();
expect(identity.findUinByUid('u_seen')).toBe(88888);
await identity.close();
});
it('does not hydrate identity-only sightings as live members after reopen', async () => {
const dbPath = tempDbPath('identity-only-not-roster');
const member = makeMember(33333, 'u_member', 'card');
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
identity.rememberGroups([makeGroup()]);
identity.rememberGroupMembers(GROUP_ID, [member]);
identity.rememberGroupMemberIdentity(GROUP_ID, { uid: 'u_operator', uin: 88888 });
expect(identity.findGroupMember(GROUP_ID, 88888)).toBeNull();
await identity.close();
}
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
expect(identity.findGroupMember(GROUP_ID, member.uin)?.card).toBe('card');
expect(identity.findGroupMember(GROUP_ID, 88888)).toBeNull();
expect(identity.findUinByUid('u_operator')).toBe(88888);
await identity.close();
}
});
it('records a thin live member on join without inventing roster details', async () => {
const identity = IdentityService.memory(SELF_UIN);
identity.rememberGroups([makeGroup()]);
const existing = makeMember(33333, 'u_existing', 'card');
identity.rememberGroupMembers(GROUP_ID, [existing]);
identity.rememberGroupMemberJoined(GROUP_ID, { uid: 'u_join', uin: 44444 });
identity.rememberGroupMemberJoined(GROUP_ID, { uid: 'u_join', uin: 44444 });
identity.rememberGroupMemberJoined(GROUP_ID, { uid: 'u_ignored', uin: 0 });
const joined = identity.findGroupMember(GROUP_ID, 44444);
expect(joined).toMatchObject({
uin: 44444,
uid: 'u_join',
nickname: '',
card: '',
role: 'member',
});
expect(joined?.isRobot).toBeUndefined();
expect(identity.findGroupMember(GROUP_ID, 33333)?.card).toBe('card');
await identity.close();
});
it('persists a thin live member on join across reopen', async () => {
const dbPath = tempDbPath('join-live-roster');
const existing = makeMember(33333, 'u_existing', 'card');
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
identity.rememberGroups([makeGroup()]);
identity.rememberGroupMembers(GROUP_ID, [existing]);
identity.rememberGroupMemberJoined(GROUP_ID, { uid: 'u_join', uin: 44444 });
await identity.close();
}
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
const joined = identity.findGroupMember(GROUP_ID, 44444);
expect(joined).toMatchObject({
uin: 44444,
uid: 'u_join',
nickname: '',
card: '',
role: 'member',
});
expect(joined?.isRobot).toBeUndefined();
expect(identity.findGroupMember(GROUP_ID, 33333)?.card).toBe('card');
await identity.close();
}
});
it('drops a leaver from the live roster and keeps their identity mapping', async () => {
const identity = IdentityService.memory(SELF_UIN);
identity.rememberGroups([makeGroup()]);
const member = makeMember(33333, 'u_member', 'card');
identity.rememberGroupMembers(GROUP_ID, [member]);
identity.markGroupMemberInactive(GROUP_ID, { uid: member.uid, uin: member.uin });
expect(identity.findGroupMember(GROUP_ID, member.uin)).toBeNull();
expect(identity.findUidByUin(member.uin, GROUP_ID)).toBe(member.uid);
await identity.close();
});
it('keeps a leaver out of the live roster after reopen', async () => {
const dbPath = tempDbPath('leave-live-roster');
const member = makeMember(33333, 'u_member', 'card');
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
identity.rememberGroups([makeGroup()]);
identity.rememberGroupMembers(GROUP_ID, [member]);
identity.markGroupMemberInactive(GROUP_ID, { uid: member.uid, uin: member.uin });
expect(identity.findGroupMember(GROUP_ID, member.uin)).toBeNull();
await identity.close();
}
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
expect(identity.findGroupMember(GROUP_ID, member.uin)).toBeNull();
expect(identity.findUidByUin(member.uin, GROUP_ID)).toBe(member.uid);
await identity.close();
}
});
it('marks missing members inactive only after a successful full refresh', async () => {
const dbPath = tempDbPath('inactive-refresh');
const first = makeMember(33333, 'u_first');
const second = makeMember(44444, 'u_second');
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
identity.rememberGroups([makeGroup()]);
identity.rememberGroupMembers(GROUP_ID, [first, second]);
identity.rememberGroupMembers(GROUP_ID, [second]);
await identity.close();
}
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
expect(identity.findGroupMember(GROUP_ID, first.uin)).toBeNull();
expect(identity.findGroupMember(GROUP_ID, second.uin)?.uid).toBe(second.uid);
// Historical identity remains available for UID/UIN resolution.
expect(identity.findUidByUin(first.uin, GROUP_ID)).toBe(first.uid);
await identity.close();
}
});
it('marks missing friends and groups inactive after successful full refreshes', async () => {
const dbPath = tempDbPath('inactive-lists');
const member = makeMember(33333, 'u_member');
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
identity.rememberFriends([
{ uin: 22222, uid: 'u_friend', nickname: 'friend', remark: '' },
]);
identity.rememberGroups([makeGroup()]);
identity.rememberGroupMembers(GROUP_ID, [member]);
identity.rememberFriends([]);
identity.rememberGroups([]);
await identity.close();
}
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
expect(identity.findFriend(22222)).toBeNull();
expect(identity.findGroup(GROUP_ID)).toBeNull();
// Identity mappings remain useful for historical events/actions.
expect(identity.findUidByUin(22222)).toBe('u_friend');
expect(identity.findUidByUin(member.uin, GROUP_ID)).toBe(member.uid);
await identity.close();
}
});
it('can mark one member inactive without losing the identity mapping', async () => {
const dbPath = tempDbPath('inactive-event');
const member = makeMember(33333, 'u_member');
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
identity.rememberGroups([makeGroup()]);
identity.rememberGroupMembers(GROUP_ID, [member]);
identity.markGroupMemberInactive(GROUP_ID, { uid: member.uid, uin: member.uin });
await identity.close();
}
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
expect(identity.findGroupMember(GROUP_ID, member.uin)).toBeNull();
expect(identity.findUinByUid(member.uid, GROUP_ID)).toBe(member.uin);
await identity.close();
}
});
it('persists identities learned from request events', async () => {
const dbPath = tempDbPath('request-events');
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
identity.rememberRequestIdentity({
uid: 'u_friend_request',
uin: 55555,
source: 'friend_request',
});
identity.rememberRequestIdentity({
groupId: GROUP_ID,
uid: 'u_group_request',
uin: 66666,
source: 'group_request',
});
await identity.close();
}
{
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
expect(identity.findUidByUin(55555)).toBe('u_friend_request');
expect(identity.findUinByUid('u_friend_request')).toBe(55555);
expect(identity.findGroup(GROUP_ID)).toBeNull();
expect(identity.findUidByUin(66666)).toBe('u_group_request');
await identity.close();
}
});
});
describe('IdentityService.resolveUid', () => {
function makeProfile(uin: number, uid: string): UserProfileInfo {
return {
uin,
uid,
nickname: '',
remark: '',
qid: '',
sex: 'unknown',
age: 0,
sign: '',
avatar: '',
level: 0,
qidianMasterFlag: 0,
qidianCrewFlag: 0,
qidianCrewFlag2: 0,
};
}
it('returns the cached uid without invoking the fetcher', async () => {
const identity = IdentityService.memory(SELF_UIN);
identity.rememberRequestIdentity({ uid: 'u_known', uin: 12345 });
const fetchProfile = vi.fn(async (uin: number) => makeProfile(uin, 'should-not-be-used'));
identity.setFetcher({ fetchProfile });
await expect(identity.resolveUid(12345)).resolves.toBe('u_known');
expect(fetchProfile).not.toHaveBeenCalled();
});
it('falls back to fetchProfile on miss and returns the resolved uid', async () => {
const identity = IdentityService.memory(SELF_UIN);
const fetchProfile = vi.fn(async (uin: number) => {
// Simulate the bridge writing the result back via rememberUserProfile.
identity.rememberUserProfile(makeProfile(uin, 'u_fetched'));
return makeProfile(uin, 'u_fetched');
});
identity.setFetcher({ fetchProfile });
await expect(identity.resolveUid(99999)).resolves.toBe('u_fetched');
expect(fetchProfile).toHaveBeenCalledWith(99999);
});
it('throws when neither cache nor fetcher can produce a uid', async () => {
const identity = IdentityService.memory(SELF_UIN);
identity.setFetcher({ fetchProfile: async (uin) => makeProfile(uin, '') });
await expect(identity.resolveUid(99999)).rejects.toThrow(/failed to resolve UID/);
});
it('tries fetchGroupMemberList before fetchProfile when groupId is provided', async () => {
const identity = IdentityService.memory(SELF_UIN);
identity.rememberGroups([
{
groupId: GROUP_ID,
groupName: '',
remark: '',
memberCount: 0,
memberMax: 0,
members: new Map(),
},
]);
const fetchProfile = vi.fn(async (uin: number) => makeProfile(uin, 'u_via_profile'));
const fetchGroupMemberList = vi.fn(async (groupId: number) => {
// Roster fetch should populate the cache.
identity.rememberGroupMembers(groupId, [makeMember(77777, 'u_via_roster')]);
return [];
});
identity.setFetcher({ fetchProfile, fetchGroupMemberList });
await expect(identity.resolveUid(77777, GROUP_ID)).resolves.toBe('u_via_roster');
expect(fetchGroupMemberList).toHaveBeenCalledWith(GROUP_ID);
expect(fetchProfile).not.toHaveBeenCalled();
});
it('throws on invalid (zero / negative) uin without calling fetcher', async () => {
const identity = IdentityService.memory(SELF_UIN);
const fetchProfile = vi.fn();
identity.setFetcher({ fetchProfile });
await expect(identity.resolveUid(0)).rejects.toThrow(/invalid uin/);
expect(fetchProfile).not.toHaveBeenCalled();
});
});
describe('IdentityService inbound UID→UIN', () => {
function makeProfile(uin: number, uid: string): UserProfileInfo {
return {
uin,
uid,
nickname: '',
remark: '',
qid: '',
sex: 'unknown',
age: 0,
sign: '',
avatar: '',
level: 0,
qidianMasterFlag: 0,
qidianCrewFlag: 0,
qidianCrewFlag2: 0,
};
}
it('findUinByUid treats a numeric uid as that uin without consulting maps', () => {
const identity = IdentityService.memory(SELF_UIN);
identity.rememberRequestIdentity({ uid: '22999', uin: 11111 });
expect(identity.findUinByUid('22999')).toBe(22999);
expect(identity.findUinByUid('0')).toBeNull();
expect(identity.findUinByUid('')).toBeNull();
});
it('returns the cached uin without invoking fetchProfileByUid', async () => {
const identity = IdentityService.memory(SELF_UIN);
identity.rememberRequestIdentity({ uid: 'u_known', uin: 12345 });
const fetchProfileByUid = vi.fn(async () => makeProfile(999, 'should-not-be-used'));
identity.setFetcher({
fetchProfile: async () => makeProfile(0, ''),
fetchProfileByUid,
});
await expect(identity.resolveUin('u_known')).resolves.toBe(12345);
expect(fetchProfileByUid).not.toHaveBeenCalled();
});
it('does not call fetchProfileByUid for a numeric uid', async () => {
const identity = IdentityService.memory(SELF_UIN);
const fetchProfileByUid = vi.fn(async () => makeProfile(1, '1'));
identity.setFetcher({
fetchProfile: async () => makeProfile(0, ''),
fetchProfileByUid,
});
await expect(identity.resolveUin('22999')).resolves.toBe(22999);
expect(fetchProfileByUid).not.toHaveBeenCalled();
});
it('falls back to fetchProfileByUid on miss and returns the profile uin', async () => {
const identity = IdentityService.memory(SELF_UIN);
const fetchProfileByUid = vi.fn(async (uid: string) => {
identity.rememberUserProfile(makeProfile(88888, uid));
return makeProfile(88888, uid);
});
identity.setFetcher({
fetchProfile: async () => makeProfile(0, ''),
fetchProfileByUid,
});
await expect(identity.resolveUin('u_stranger')).resolves.toBe(88888);
expect(fetchProfileByUid).toHaveBeenCalledWith('u_stranger');
});
it('returns the profile uin even when the fetcher forgets to remember', async () => {
const identity = IdentityService.memory(SELF_UIN);
identity.setFetcher({
fetchProfile: async () => makeProfile(0, ''),
fetchProfileByUid: async () => makeProfile(77777, 'u_forgetful'),
});
await expect(identity.resolveUin('u_forgetful')).resolves.toBe(77777);
});
it('does not pull a group member list even when groupId is provided', async () => {
const identity = IdentityService.memory(SELF_UIN);
const fetchGroupMemberList = vi.fn(async () => []);
const fetchProfileByUid = vi.fn(async () => makeProfile(66666, 'u_invitee'));
identity.setFetcher({
fetchProfile: async () => makeProfile(0, ''),
fetchProfileByUid,
fetchGroupMemberList,
});
await expect(identity.resolveUin('u_invitee', GROUP_ID)).resolves.toBe(66666);
expect(fetchGroupMemberList).not.toHaveBeenCalled();
expect(fetchProfileByUid).toHaveBeenCalledWith('u_invitee');
});
it('does not pull a group member list when the inbound fetcher is absent', async () => {
const identity = IdentityService.memory(SELF_UIN);
const fetchGroupMemberList = vi.fn(async () => []);
identity.setFetcher({
fetchProfile: async () => makeProfile(0, ''),
fetchGroupMemberList,
});
await expect(identity.resolveUin('u_none', GROUP_ID)).resolves.toBeNull();
expect(fetchGroupMemberList).not.toHaveBeenCalled();
});
it('uses a mapping remembered before the inbound fetcher throws', async () => {
const identity = IdentityService.memory(SELF_UIN);
identity.setFetcher({
fetchProfile: async () => makeProfile(0, ''),
fetchProfileByUid: async (uid: string) => {
identity.rememberUserProfile(makeProfile(55555, uid));
throw new Error('transport reset after remember');
},
});
await expect(identity.resolveUin('u_remembered')).resolves.toBe(55555);
});
it('returns null on miss, fetcher error, empty profile, or missing inbound fetcher', async () => {
const identity = IdentityService.memory(SELF_UIN);
await expect(identity.resolveUin('u_none')).resolves.toBeNull();
identity.setFetcher({ fetchProfile: async () => makeProfile(0, '') });
await expect(identity.resolveUin('u_none')).resolves.toBeNull();
identity.setFetcher({
fetchProfile: async () => makeProfile(0, ''),
fetchProfileByUid: async () => makeProfile(0, 'u_none'),
});
await expect(identity.resolveUin('u_none')).resolves.toBeNull();
identity.setFetcher({
fetchProfile: async () => makeProfile(0, ''),
fetchProfileByUid: async () => {
throw new Error('oidb down');
},
});
await expect(identity.resolveUin('u_none')).resolves.toBeNull();
});
it('does not enter Identity degraded when the inbound fetcher throws', async () => {
const dbPath = tempDbPath('inbound-fetch-throw');
const identity = new IdentityService(SELF_UIN, dbPath);
await identity.initialization;
identity.setFetcher({
fetchProfile: async () => makeProfile(0, ''),
fetchProfileByUid: async () => {
throw new Error('oidb down');
},
});
await expect(identity.resolveUin('u_none')).resolves.toBeNull();
expect(identity.persistenceStatus.state).toBe('healthy');
expect(identity.persistenceStatus.pendingWrites).toBe(0);
await identity.close();
});
});