kt-template-online-api/test/qqbot/account/qqbot-account.service.spec.ts

1614 lines
52 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.

import { ToolsService } from '@/common';
import type { QqbotAccountNapcatRuntimePort } from '@/modules/qqbot/core/application/account/qqbot-account-napcat-runtime.port';
import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service';
import { QqbotAccountAbility } from '@/modules/qqbot/core/infrastructure/persistence/account/qqbot-account-ability.entity';
import { QqbotAccount } from '@/modules/qqbot/core/infrastructure/persistence/account/qqbot-account.entity';
import { QqbotMessageDelivery } from '@/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-delivery.entity';
import { QqbotMessagePublishBinding } from '@/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-binding.entity';
import { QqbotNapcatAccountRuntimeService } from '@/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service';
/**
* 创建 测试断言对象或配置。
* @param input - input 输入;使用 `accountRepository`、`accountAbilityRepository`、`toolsService`、`napcatRuntime` 字段生成结果。
*/
const createAccountService = (input: {
accountAbilityRepository?: any;
accountRepository: any;
configService?: any;
napcatRuntime?: QqbotAccountNapcatRuntimePort;
passwordCryptoService?: any;
systemNoticePublisher?: any;
toolsService?: ToolsService;
}) =>
new QqbotAccountService(
input.accountRepository,
input.accountAbilityRepository || {},
input.toolsService || new ToolsService(),
input.napcatRuntime,
input.systemNoticePublisher,
input.configService,
input.passwordCryptoService,
);
/**
* 创建 测试断言对象或配置。
* @param input - input 输入;使用 `bindingRepository`、`containerRepository`、`containerService`、`toolsService` 字段生成结果。
*/
const createNapcatRuntime = (input: {
bindingRepository: any;
containerRepository: any;
containerService: any;
toolsService?: ToolsService;
}) =>
new QqbotNapcatAccountRuntimeService(
input.bindingRepository,
input.containerRepository,
input.containerService,
input.toolsService || new ToolsService(),
);
/**
* 创建 测试断言对象或配置。
* @param input - input 输入;使用 `toolsService`、`accountRepository`、`configService`、`bindingRepository` 字段生成结果。
*/
const createAccountServiceWithNapcatRuntime = (input: {
accountRepository: any;
bindingRepository: any;
configService?: any;
containerRepository: any;
containerService: any;
passwordCryptoService?: any;
systemNoticePublisher?: any;
toolsService?: ToolsService;
}) => {
const toolsService = input.toolsService || new ToolsService();
return createAccountService({
accountRepository: input.accountRepository,
configService: input.configService,
napcatRuntime: createNapcatRuntime({
bindingRepository: input.bindingRepository,
containerRepository: input.containerRepository,
containerService: input.containerService,
toolsService,
}),
passwordCryptoService: input.passwordCryptoService,
systemNoticePublisher: input.systemNoticePublisher,
toolsService,
});
};
describe('QqbotAccountService', () => {
it('fences administrative account cancellation to unfinished delivery statuses', async () => {
const deliveryUpdate = jest.fn().mockResolvedValue({ affected: 2 });
const manager = {
getRepository: (entity: unknown) => {
if (entity === QqbotMessagePublishBinding) {
return { find: jest.fn().mockResolvedValue([{ id: 'binding-1' }]) };
}
if (entity === QqbotMessageDelivery) return { update: deliveryUpdate };
throw new Error('unexpected repository');
},
};
const service = createAccountService({ accountRepository: {} });
await (service as any).cancelAccountDeliveries(manager, 'account-1');
expect(deliveryUpdate).toHaveBeenCalledWith(
expect.objectContaining({
bindingId: expect.anything(),
status: expect.anything(),
}),
expect.objectContaining({ status: 'cancelled' }),
);
const [where] = deliveryUpdate.mock.calls[0];
expect(where.status.value).toEqual(['waiting_ddns', 'pending', 'retry']);
});
describe('administrative delivery cancellation transaction', () => {
/** Builds account/ability/delivery authority with isolated commit-on-resolve drafts. */
function cancellationHarness() {
const accounts = [
{
accessToken: null,
connectionMode: 'reverse-ws',
enabled: true,
id: 'account-1',
isDeleted: false,
name: 'Primary',
remark: '',
selfId: '1914728559',
},
];
const abilities = [
{
accountId: 'account-1',
id: 'ability-1',
isDeleted: false,
selfId: '1914728559',
},
];
const bindings = [
{ accountId: 'account-1', id: 'binding-1' },
{ accountId: 'account-1', id: 'binding-2' },
{ accountId: 'account-2', id: 'binding-other' },
];
const deliveries = [
...['waiting_ddns', 'pending', 'retry', 'processing', 'success'].map(
(status) => ({
bindingId: 'binding-1',
frozen: `binding-1-${status}`,
id: `binding-1-${status}`,
nextAttemptAt: `schedule-${status}`,
processingLeaseUntil: `lease-${status}`,
status,
}),
),
{
bindingId: 'binding-2',
frozen: 'binding-2',
id: 'binding-2-pending',
nextAttemptAt: 'schedule-binding-2',
processingLeaseUntil: 'lease-binding-2',
status: 'pending',
},
{
bindingId: 'binding-other',
frozen: 'other',
id: 'binding-other-pending',
nextAttemptAt: 'schedule-other',
processingLeaseUntil: 'lease-other',
status: 'pending',
},
];
const operations: string[] = [];
let failCancellation = false;
const transaction = jest.fn(
async (work: (manager: any) => Promise<unknown>) => {
operations.push('transaction:start');
const draftAccounts = structuredClone(accounts);
const draftAbilities = structuredClone(abilities);
const draftDeliveries = structuredClone(deliveries);
const accountStore = {
findOne: jest.fn(
async ({
lock,
where,
}: {
lock?: { mode: string };
where: Record<string, unknown>;
}) => {
if (lock) operations.push('account:lock');
const row = draftAccounts.find((candidate) =>
Object.entries(where).every(
([key, value]) => candidate[key] === value,
),
);
return row ? structuredClone(row) : null;
},
),
update: jest.fn(
async (
where: Record<string, unknown>,
patch: Record<string, unknown>,
) => {
operations.push('account:update');
const row = draftAccounts.find((candidate) =>
Object.entries(where).every(
([key, value]) => candidate[key] === value,
),
);
if (!row) return { affected: 0 };
Object.assign(row, patch);
return { affected: 1 };
},
),
};
const manager = {
getRepository: (entity: unknown) => {
if (entity === QqbotAccount) return accountStore;
if (entity === QqbotAccountAbility) {
return {
update: async (
where: Record<string, unknown>,
patch: Record<string, unknown>,
) => {
operations.push('ability:update');
draftAbilities
.filter((row) =>
Object.entries(where).every(
([key, value]) => row[key] === value,
),
)
.forEach((row) => Object.assign(row, patch));
return { affected: 1 };
},
};
}
if (entity === QqbotMessagePublishBinding) {
return {
find: async ({
where,
}: {
where: Record<string, unknown>;
}) => {
operations.push('bindings:read');
return bindings.filter((row) =>
Object.entries(where).every(
([key, value]) => row[key] === value,
),
);
},
};
}
if (entity === QqbotMessageDelivery) {
return {
update: async (
where: Record<string, any>,
patch: Record<string, unknown>,
) => {
operations.push('delivery:update');
const bindingIds = where.bindingId._value as string[];
const statuses = where.status._value as string[];
draftDeliveries.forEach((row) => {
if (
bindingIds.includes(row.bindingId) &&
statuses.includes(row.status)
) {
Object.assign(row, patch);
}
});
if (failCancellation) {
throw new Error('account cancellation failed');
}
return { affected: 4 };
},
};
}
throw new Error('unexpected account repository');
},
};
const result = await work(manager);
accounts.splice(0, accounts.length, ...draftAccounts);
abilities.splice(0, abilities.length, ...draftAbilities);
deliveries.splice(0, deliveries.length, ...draftDeliveries);
operations.push('transaction:commit');
return result;
},
);
const accountRepository = {
findOne: jest.fn(
async ({ where }: { where: Record<string, unknown> }) => {
const row = accounts.find((candidate) =>
Object.entries(where).every(
([key, value]) => candidate[key] === value,
),
);
return row ? structuredClone(row) : null;
},
),
manager: { transaction },
update: jest.fn(),
};
const removeAccountContainers = jest.fn(async () => {
operations.push('external:remove');
return { deletedContainers: 1 };
});
const service = createAccountService({
accountAbilityRepository: { update: jest.fn() },
accountRepository,
napcatRuntime: { removeAccountContainers } as never,
});
return {
abilities,
accounts,
deliveries,
failCancellation: () => {
failCancellation = true;
},
operations,
removeAccountContainers,
service,
transaction,
};
}
/** Asserts only account-owned claimable delivery rows entered cancellation state. */
function expectAccountDeliveriesCancelled(
before: Array<Record<string, unknown>>,
after: Array<Record<string, unknown>>,
): void {
expect(after).toEqual(
before.map((delivery) =>
['binding-1', 'binding-2'].includes(String(delivery.bindingId)) &&
['waiting_ddns', 'pending', 'retry'].includes(String(delivery.status))
? {
...delivery,
nextAttemptAt: null,
processingLeaseUntil: null,
status: 'cancelled',
}
: delivery,
),
);
}
it.each([
[
'administrative disable',
async (service: QqbotAccountService) =>
service.update({ enabled: false, id: 'account-1' }),
],
[
'actual selfId replacement',
async (service: QqbotAccountService) =>
service.update({
id: 'account-1',
selfId: '1914728560',
}),
],
[
'administrative delete',
async (service: QqbotAccountService) => service.remove('account-1'),
],
])(
'%s commits account state and exact delivery cancellations',
async (_name, mutate) => {
const harness = cancellationHarness();
const before = structuredClone(harness.deliveries);
await mutate(harness.service);
expectAccountDeliveriesCancelled(before, harness.deliveries);
expect(harness.operations.indexOf('account:lock')).toBeLessThan(
harness.operations.indexOf('bindings:read'),
);
expect(harness.operations.indexOf('bindings:read')).toBeLessThan(
harness.operations.indexOf('delivery:update'),
);
expect(harness.operations.at(-1)).toBe('transaction:commit');
},
);
it('same selfId and metadata-only updates do not cancel frozen deliveries', async () => {
const sameSelfId = cancellationHarness();
const sameBefore = structuredClone(sameSelfId.deliveries);
await sameSelfId.service.update({
id: 'account-1',
name: 'Renamed',
selfId: '1914728559',
});
expect(sameSelfId.deliveries).toEqual(sameBefore);
expect(sameSelfId.operations).not.toContain('delivery:update');
const metadata = cancellationHarness();
const metadataBefore = structuredClone(metadata.deliveries);
await metadata.service.update({ id: 'account-1', remark: 'metadata' });
expect(metadata.deliveries).toEqual(metadataBefore);
expect(metadata.operations).not.toContain('delivery:update');
});
it('runtime online/offline changes stay outside administrative cancellation', async () => {
const harness = cancellationHarness();
const before = structuredClone(harness.deliveries);
await harness.service.markOnline('1914728559', 'Universal');
await harness.service.markOffline('1914728559');
await harness.service.markHeartbeat('1914728559');
expect(harness.transaction).not.toHaveBeenCalled();
expect(harness.deliveries).toEqual(before);
});
it('rolls back account, ability, and delivery drafts together when cancellation fails', async () => {
const harness = cancellationHarness();
const before = {
abilities: structuredClone(harness.abilities),
accounts: structuredClone(harness.accounts),
deliveries: structuredClone(harness.deliveries),
};
harness.failCancellation();
await expect(
harness.service.update({
id: 'account-1',
selfId: '1914728560',
}),
).rejects.toThrow('account cancellation failed');
expect(harness.accounts).toEqual(before.accounts);
expect(harness.abilities).toEqual(before.abilities);
expect(harness.deliveries).toEqual(before.deliveries);
expect(harness.operations).not.toContain('transaction:commit');
});
it('keeps external removal outside the DB transaction while rolling back authoritative delete state', async () => {
const harness = cancellationHarness();
const before = {
abilities: structuredClone(harness.abilities),
accounts: structuredClone(harness.accounts),
deliveries: structuredClone(harness.deliveries),
};
harness.failCancellation();
await expect(harness.service.remove('account-1')).rejects.toThrow(
'account cancellation failed',
);
expect(harness.operations[0]).toBe('external:remove');
expect(harness.accounts).toEqual(before.accounts);
expect(harness.abilities).toEqual(before.abilities);
expect(harness.deliveries).toEqual(before.deliveries);
expect(harness.removeAccountContainers).toHaveBeenCalledTimes(1);
});
});
it('stores NapCat login password as encrypted secret and never persists the transport field', async () => {
const toolsService = new ToolsService();
const accountRepository = {
create: jest.fn((input) => input),
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn(async (input) => ({ ...input, id: 'account-1' })),
};
const service = createAccountService({
accountRepository,
configService: {
get: jest.fn((key: string) =>
key === 'QQBOT_ACCOUNT_SECRET_KEY' ? 'unit-secret' : '',
),
},
passwordCryptoService: {
decryptPassword: jest.fn().mockReturnValue('qq-login-password'),
},
toolsService,
});
await service.save({
encryptedLoginPassword: 'encrypted-payload',
selfId: '1914728559',
});
const payload = accountRepository.create.mock.calls[0][0];
expect(payload.encryptedLoginPassword).toBeUndefined();
expect(payload.napcatLoginPasswordSecret).toBeTruthy();
expect(payload.napcatLoginPasswordSecret).not.toContain(
'qq-login-password',
);
expect(
toolsService.decryptSecretText(
payload.napcatLoginPasswordSecret,
'unit-secret',
),
).toBe('qq-login-password');
});
it('requires an explicit secret key before storing NapCat login password', async () => {
const accountRepository = {
create: jest.fn((input) => input),
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn(async (input) => ({ ...input, id: 'account-1' })),
};
const service = createAccountService({
accountRepository,
configService: {
get: jest.fn().mockReturnValue(''),
},
passwordCryptoService: {
decryptPassword: jest.fn().mockReturnValue('qq-login-password'),
},
});
await expect(
service.save({
encryptedLoginPassword: 'encrypted-payload',
selfId: '1914728559',
}),
).rejects.toMatchObject({
response: expect.objectContaining({
msg: 'QQBot 账号登录密码密钥未配置,请设置 QQBOT_ACCOUNT_SECRET_KEY 或 ADMIN_TOKEN_SECRET',
}),
});
expect(accountRepository.save).not.toHaveBeenCalled();
});
it('rejects public placeholder secret before storing NapCat login password', async () => {
const accountRepository = {
create: jest.fn((input) => input),
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn(async (input) => ({ ...input, id: 'account-1' })),
};
const service = createAccountService({
accountRepository,
configService: {
get: jest.fn((key: string) =>
key === 'ADMIN_TOKEN_SECRET' ? 'change-me' : '',
),
},
passwordCryptoService: {
decryptPassword: jest.fn().mockReturnValue('qq-login-password'),
},
});
await expect(
service.save({
encryptedLoginPassword: 'encrypted-payload',
selfId: '1914728559',
}),
).rejects.toMatchObject({
response: expect.objectContaining({
msg: 'QQBot 账号登录密码密钥未配置,请设置 QQBOT_ACCOUNT_SECRET_KEY 或 ADMIN_TOKEN_SECRET',
}),
});
expect(accountRepository.save).not.toHaveBeenCalled();
});
it('preserves NapCat login password whitespace after decryption', async () => {
const toolsService = new ToolsService();
const accountRepository = {
create: jest.fn((input) => input),
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn(async (input) => ({ ...input, id: 'account-1' })),
};
const service = createAccountService({
accountRepository,
configService: {
get: jest.fn((key: string) =>
key === 'QQBOT_ACCOUNT_SECRET_KEY' ? 'unit-secret' : '',
),
},
passwordCryptoService: {
decryptPassword: jest.fn().mockReturnValue(' qq-login-password '),
},
toolsService,
});
await service.save({
encryptedLoginPassword: 'encrypted-payload',
selfId: '1914728559',
});
const payload = accountRepository.create.mock.calls[0][0];
expect(
toolsService.decryptSecretText(
payload.napcatLoginPasswordSecret,
'unit-secret',
),
).toBe(' qq-login-password ');
});
it('does not update NapCat login password when edit leaves the password blank', async () => {
const accountUpdate = jest.fn().mockResolvedValue({ affected: 1 });
const account = {
enabled: true,
id: 'account-1',
isDeleted: false,
selfId: '1914728559',
};
const accountRepository = {
findOne: jest.fn().mockResolvedValue(account),
manager: {
transaction: async (work: (manager: any) => Promise<unknown>) =>
work({
getRepository: (entity: unknown) => {
if (entity === QqbotAccount) {
return {
findOne: jest.fn().mockResolvedValue({ ...account }),
update: accountUpdate,
};
}
throw new Error('unexpected repository');
},
}),
},
update: jest.fn(),
};
const service = createAccountService({
accountAbilityRepository: { update: jest.fn() },
accountRepository,
});
await service.update({
id: 'account-1',
name: 'Mirror',
selfId: '1914728559',
});
expect(accountUpdate).toHaveBeenCalledWith(
{ id: 'account-1' },
expect.not.objectContaining({
encryptedLoginPassword: expect.anything(),
napcatLoginPasswordSecret: expect.anything(),
}),
);
});
it('preserves previous offline reason when later disconnect has no explicit error', async () => {
const accountRepository = {
update: jest.fn(),
};
const service = createAccountService({ accountRepository });
await service.markOffline('1914728559');
expect(accountRepository.update).toHaveBeenCalledWith(
{ selfId: '1914728559' },
expect.objectContaining({
connectStatus: 'offline',
oneBotStatus: 'offline',
}),
);
});
it('preserves QQ login error when OneBot connection comes online', async () => {
const accountRepository = {
update: jest.fn(),
};
const service = createAccountService({ accountRepository });
await service.markOnline('1914728559', 'Universal');
const updatePayload = accountRepository.update.mock.calls[0][1];
expect(updatePayload).toEqual(
expect.objectContaining({
clientRole: 'Universal',
connectStatus: 'online',
lastConnectedAt: expect.any(Date),
}),
);
expect(updatePayload).not.toHaveProperty('lastError');
});
it('clears QQ login error only when online state is explicitly confirmed', async () => {
const accountRepository = {
update: jest.fn(),
};
const service = createAccountService({ accountRepository });
await service.markOnline('1914728559', 'Universal', null);
expect(accountRepository.update).toHaveBeenCalledWith(
{ selfId: '1914728559' },
expect.objectContaining({
clientRole: 'Universal',
connectStatus: 'online',
lastConnectedAt: expect.any(Date),
lastError: null,
}),
);
});
it('persists split OneBot online status when heartbeat arrives', async () => {
const accountRepository = {
update: jest.fn(),
};
const service = createAccountService({ accountRepository });
await service.markHeartbeat('1914728559');
expect(accountRepository.update).toHaveBeenCalledWith(
{ selfId: '1914728559' },
expect.objectContaining({
connectStatus: 'online',
lastHeartbeatAt: expect.any(Date),
oneBotStatus: 'online',
}),
);
});
it('truncates offline reason before writing lastError column', async () => {
const accountRepository = {
update: jest.fn(),
};
const service = createAccountService({ accountRepository });
await service.markOffline('1914728559', '错误'.repeat(300));
expect(accountRepository.update).toHaveBeenCalledWith(
{ selfId: '1914728559' },
expect.objectContaining({
connectStatus: 'offline',
lastError: `${'错误'.repeat(248)}错...`,
oneBotStatus: 'offline',
}),
);
});
it('syncs NapCat runtime offline logs back to account list status', async () => {
const account = {
connectStatus: 'online',
enabled: true,
id: 'account-1',
isDeleted: false,
lastError: null,
name: '主账号',
selfId: '1914728559',
};
const binding = {
accountId: 'account-1',
bindStatus: 'bound',
containerId: 'container-1',
isDeleted: false,
isPrimary: true,
lastLoginAt: new Date('2026-06-10T12:00:00.000Z'),
};
const container = {
id: 'container-1',
isDeleted: false,
lastError: null,
name: 'kt-qqbot-napcat-1914728559',
status: 'running',
webuiPort: 6101,
};
/**
* 创建 账号。
*/
const createAccountBuilder = () => ({
andWhere: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[account], 1]),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
});
/**
* 创建 账号绑定。
*/
const createBindingBuilder = () => ({
addOrderBy: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([binding]),
orderBy: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
});
/**
* 创建 容器。
*/
const createContainerBuilder = () => ({
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([container]),
where: jest.fn().mockReturnThis(),
});
const accountRepository = {
createQueryBuilder: jest.fn(createAccountBuilder),
update: jest.fn(),
};
const napcatContainerService = {
detectRuntimeOffline: jest
.fn()
.mockResolvedValue('NapCat 账号状态变更为离线'),
};
const systemNoticePublisher = {
publishSystemNotice: jest.fn().mockResolvedValue('notice-1'),
};
const service = createAccountServiceWithNapcatRuntime({
accountRepository,
bindingRepository: { createQueryBuilder: jest.fn(createBindingBuilder) },
containerRepository: {
createQueryBuilder: jest.fn(createContainerBuilder),
},
containerService: napcatContainerService,
systemNoticePublisher,
});
const page = await service.page({});
expect(accountRepository.update).toHaveBeenCalledWith(
{ selfId: '1914728559' },
expect.objectContaining({
lastError: 'NapCat 账号状态变更为离线',
qqLoginStatus: 'offline',
}),
);
expect(page.list[0]).toEqual(
expect.objectContaining({
connectStatus: 'online',
lastError: 'NapCat 账号状态变更为离线',
napcat: expect.objectContaining({
oneBotOnline: true,
qqLoginStatus: 'offline',
}),
}),
);
expect(systemNoticePublisher.publishSystemNotice).toHaveBeenCalledWith(
expect.objectContaining({
content:
'NapCat 账号状态变更为离线\n请在 Admin 的 QQBot 账号页面手动点击「更新登录」重新登录。',
dedupeKey: 'qqbot:offline:1914728559',
eventType: 'qqbot.account.offline',
notifyRoleCode: 'super',
severity: 'error',
source: 'qqbot',
title: 'QQBot 账号已下线1914728559',
}),
);
});
it('does not re-read NapCat logs when the container was checked recently', async () => {
const account = {
connectStatus: 'online',
enabled: true,
id: 'account-1',
isDeleted: false,
lastError: null,
name: '主账号',
selfId: '1914728559',
};
const binding = {
accountId: 'account-1',
bindStatus: 'bound',
containerId: 'container-1',
isDeleted: false,
isPrimary: true,
lastLoginAt: new Date('2026-06-10T12:00:00.000Z'),
};
const container = {
id: 'container-1',
isDeleted: false,
lastCheckedAt: new Date(),
lastError: null,
name: 'kt-qqbot-napcat-1914728559',
status: 'running',
webuiPort: 6101,
};
const accountRepository = {
createQueryBuilder: jest.fn(() => ({
andWhere: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[account], 1]),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
update: jest.fn(),
};
const napcatContainerService = {
detectRuntimeOffline: jest.fn(),
};
const service = createAccountServiceWithNapcatRuntime({
accountRepository,
bindingRepository: {
createQueryBuilder: jest.fn(() => ({
addOrderBy: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([binding]),
orderBy: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
},
containerRepository: {
createQueryBuilder: jest.fn(() => ({
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([container]),
where: jest.fn().mockReturnThis(),
})),
},
containerService: napcatContainerService,
});
const page = await service.page({});
expect(napcatContainerService.detectRuntimeOffline).not.toHaveBeenCalled();
expect(accountRepository.update).not.toHaveBeenCalled();
expect(page.list[0]).toEqual(
expect.objectContaining({
connectStatus: 'online',
napcat: expect.objectContaining({
lastCheckedAt: container.lastCheckedAt,
}),
}),
);
});
it('separates OneBot, container, WebUI and QQ login status for offline accounts', async () => {
const account = {
connectStatus: 'offline',
enabled: true,
id: 'account-1',
isDeleted: false,
lastError: null,
name: '主账号',
selfId: '1914728559',
};
const binding = {
accountId: 'account-1',
bindStatus: 'bound',
containerId: 'container-1',
isDeleted: false,
isPrimary: true,
lastLoginAt: new Date('2026-06-10T12:00:00.000Z'),
};
const container = {
id: 'container-1',
isDeleted: false,
lastCheckedAt: new Date('2026-06-11T02:00:00.000Z'),
lastError: null,
name: 'kt-qqbot-napcat-1914728559',
status: 'running',
webuiPort: 6101,
};
const accountRepository = {
createQueryBuilder: jest.fn(() => ({
andWhere: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[account], 1]),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
update: jest.fn(),
};
const napcatContainerService = {
inspectRuntimeStatus: jest.fn().mockResolvedValue({
checkedAt: new Date('2026-06-12T12:00:00.000Z'),
containerOnline: true,
lastError: '二维码已过期,请刷新',
qqLoginMessage: '二维码已过期,请刷新',
qqLoginStatus: 'qrcode_expired',
webuiOnline: true,
}),
};
const service = createAccountServiceWithNapcatRuntime({
accountRepository,
bindingRepository: {
createQueryBuilder: jest.fn(() => ({
addOrderBy: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([binding]),
orderBy: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
},
containerRepository: {
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([container]),
where: jest.fn().mockReturnThis(),
})),
},
containerService: napcatContainerService,
});
const page = await service.page({});
expect(napcatContainerService.inspectRuntimeStatus).toHaveBeenCalledWith(
container,
);
expect(accountRepository.update).not.toHaveBeenCalled();
expect(page.list[0]).toEqual(
expect.objectContaining({
connectStatus: 'offline',
napcat: expect.objectContaining({
containerOnline: true,
oneBotOnline: false,
qqLoginMessage: '二维码已过期,请刷新',
qqLoginStatus: 'qrcode_expired',
webuiOnline: true,
}),
}),
);
});
it('does not expose WebUI errors as QQ login messages in cached runtime status', async () => {
const checkedAt = new Date();
const account = {
connectStatus: 'offline',
enabled: true,
id: 'account-1',
isDeleted: false,
lastError: null,
name: '主账号',
selfId: '1914728559',
};
const binding = {
accountId: 'account-1',
bindStatus: 'bound',
containerId: 'container-1',
isDeleted: false,
isPrimary: true,
lastLoginAt: checkedAt,
};
const container = {
id: 'container-1',
isDeleted: false,
lastCheckedAt: checkedAt,
lastError: 'NapCat WebUI 配置缺失',
name: 'kt-qqbot-napcat-1914728559',
status: 'running',
webuiPort: 6101,
};
const accountRepository = {
createQueryBuilder: jest.fn(() => ({
andWhere: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[account], 1]),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
update: jest.fn(),
};
const service = createAccountServiceWithNapcatRuntime({
accountRepository,
bindingRepository: {
createQueryBuilder: jest.fn(() => ({
addOrderBy: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([binding]),
orderBy: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
},
containerRepository: {
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([container]),
where: jest.fn().mockReturnThis(),
})),
},
containerService: {},
});
const page = await service.page({});
expect(page.list[0].napcat).toEqual(
expect.objectContaining({
containerOnline: true,
lastError: 'NapCat WebUI 配置缺失',
qqLoginMessage: null,
qqLoginStatus: 'unknown',
webuiOnline: null,
}),
);
});
it('does not derive QQ login online status from OneBot heartbeat cache', async () => {
const checkedAt = new Date();
const account = {
connectStatus: 'online',
enabled: true,
id: 'account-1',
isDeleted: false,
lastError: null,
lastHeartbeatAt: new Date(),
name: '主账号',
selfId: '1914728559',
};
const binding = {
accountId: 'account-1',
bindStatus: 'bound',
containerId: 'container-1',
isDeleted: false,
isPrimary: true,
lastLoginAt: checkedAt,
};
const container = {
id: 'container-1',
isDeleted: false,
lastCheckedAt: checkedAt,
lastError: null,
name: 'kt-qqbot-napcat-1914728559',
status: 'running',
webuiPort: 6101,
};
const accountRepository = {
createQueryBuilder: jest.fn(() => ({
andWhere: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[account], 1]),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
update: jest.fn(),
};
const service = createAccountServiceWithNapcatRuntime({
accountRepository,
bindingRepository: {
createQueryBuilder: jest.fn(() => ({
addOrderBy: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([binding]),
orderBy: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
},
containerRepository: {
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([container]),
where: jest.fn().mockReturnThis(),
})),
},
containerService: {},
});
const page = await service.page({});
expect(page.list[0].napcat).toEqual(
expect.objectContaining({
oneBotOnline: true,
qqLoginStatus: 'unknown',
webuiOnline: null,
}),
);
});
it('ignores cached NapCat offline reason after the account reconnects', async () => {
const now = Date.now();
const checkedAt = new Date(now - 10_000);
const account = {
connectStatus: 'online',
enabled: true,
id: 'account-1',
isDeleted: false,
lastConnectedAt: new Date(now - 5_000),
lastError: null,
name: '主账号',
selfId: '1914728559',
};
const binding = {
accountId: 'account-1',
bindStatus: 'bound',
containerId: 'container-1',
isDeleted: false,
isPrimary: true,
lastLoginAt: checkedAt,
};
const container = {
id: 'container-1',
isDeleted: false,
lastCheckedAt: checkedAt,
lastError: '账号状态变更为离线',
name: 'kt-qqbot-napcat-1914728559',
status: 'running',
webuiPort: 6101,
};
const accountRepository = {
createQueryBuilder: jest.fn(() => ({
andWhere: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[account], 1]),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
update: jest.fn(),
};
const napcatContainerService = {
detectRuntimeOffline: jest.fn(),
};
const systemNoticePublisher = {
publishSystemNotice: jest.fn().mockResolvedValue(undefined),
};
const service = createAccountServiceWithNapcatRuntime({
accountRepository,
bindingRepository: {
createQueryBuilder: jest.fn(() => ({
addOrderBy: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([binding]),
orderBy: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
},
containerRepository: {
createQueryBuilder: jest.fn(() => ({
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([container]),
where: jest.fn().mockReturnThis(),
})),
},
containerService: napcatContainerService,
systemNoticePublisher,
});
const page = await service.page({});
expect(napcatContainerService.detectRuntimeOffline).not.toHaveBeenCalled();
expect(accountRepository.update).not.toHaveBeenCalled();
expect(systemNoticePublisher.publishSystemNotice).not.toHaveBeenCalled();
expect(page.list[0]).toEqual(
expect.objectContaining({
connectStatus: 'online',
lastError: null,
}),
);
});
it('does not let heartbeat bypass stale NapCat offline inspection', async () => {
const checkedAt = new Date(Date.now() - 60_000);
const account = {
connectStatus: 'online',
enabled: true,
id: 'account-1',
isDeleted: false,
lastConnectedAt: new Date(Date.now() - 120_000),
lastError: null,
lastHeartbeatAt: new Date(),
name: '主账号',
selfId: '1914728559',
};
const binding = {
accountId: 'account-1',
bindStatus: 'bound',
containerId: 'container-1',
isDeleted: false,
isPrimary: true,
lastLoginAt: checkedAt,
};
const container = {
id: 'container-1',
isDeleted: false,
lastCheckedAt: checkedAt,
lastError: null,
name: 'kt-qqbot-napcat-1914728559',
status: 'running',
webuiPort: 6101,
};
const accountRepository = {
createQueryBuilder: jest.fn(() => ({
andWhere: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[account], 1]),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
update: jest.fn(),
};
const napcatContainerService = {
inspectRuntimeStatus: jest.fn().mockResolvedValue({
checkedAt: new Date(),
containerOnline: true,
lastError: '账号状态变更为离线',
qqLoginMessage: '账号状态变更为离线',
qqLoginStatus: 'offline',
webuiOnline: true,
}),
};
const service = createAccountServiceWithNapcatRuntime({
accountRepository,
bindingRepository: {
createQueryBuilder: jest.fn(() => ({
addOrderBy: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([binding]),
orderBy: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
},
containerRepository: {
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([container]),
where: jest.fn().mockReturnThis(),
})),
},
containerService: napcatContainerService,
});
const page = await service.page({});
expect(napcatContainerService.inspectRuntimeStatus).toHaveBeenCalledWith(
container,
);
expect(accountRepository.update).toHaveBeenCalledWith(
{ selfId: '1914728559' },
expect.objectContaining({
lastError: '账号状态变更为离线',
qqLoginStatus: 'offline',
}),
);
expect(page.list[0]).toEqual(
expect.objectContaining({
connectStatus: 'online',
lastError: '账号状态变更为离线',
napcat: expect.objectContaining({
oneBotOnline: true,
qqLoginStatus: 'offline',
}),
}),
);
});
it('clears previous QQ login error when NapCat WebUI confirms QQ is online', async () => {
const checkedAt = new Date(Date.now() - 60_000);
const account = {
containerStatus: 'unknown',
connectStatus: 'online',
enabled: true,
id: 'account-1',
isDeleted: false,
lastConnectedAt: new Date(Date.now() - 120_000),
lastError: '账号状态变更为离线',
name: '主账号',
oneBotStatus: 'offline',
qqLoginStatus: 'unknown',
selfId: '1914728559',
webuiStatus: 'unknown',
};
const binding = {
accountId: 'account-1',
bindStatus: 'bound',
containerId: 'container-1',
isDeleted: false,
isPrimary: true,
lastLoginAt: checkedAt,
};
const container = {
id: 'container-1',
isDeleted: false,
lastCheckedAt: checkedAt,
lastError: '账号状态变更为离线',
name: 'kt-qqbot-napcat-1914728559',
status: 'running',
webuiPort: 6101,
};
const accountRepository = {
createQueryBuilder: jest.fn(() => ({
andWhere: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[account], 1]),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
update: jest.fn(),
};
const napcatContainerService = {
inspectRuntimeStatus: jest.fn().mockResolvedValue({
checkedAt: new Date(),
containerOnline: true,
lastError: null,
qqLoginMessage: null,
qqLoginStatus: 'online',
webuiOnline: true,
}),
};
const service = createAccountServiceWithNapcatRuntime({
accountRepository,
bindingRepository: {
createQueryBuilder: jest.fn(() => ({
addOrderBy: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([binding]),
orderBy: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
},
containerRepository: {
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([container]),
where: jest.fn().mockReturnThis(),
})),
},
containerService: napcatContainerService,
});
const page = await service.page({});
expect(accountRepository.update).toHaveBeenCalledWith(
{ selfId: '1914728559' },
{ lastError: null },
);
expect(accountRepository.update).toHaveBeenCalledWith(
{ id: 'account-1' },
expect.objectContaining({
containerStatus: 'running',
oneBotStatus: 'online',
qqLoginStatus: 'online',
webuiStatus: 'online',
}),
);
expect(page.list[0]).toEqual(
expect.objectContaining({
lastError: null,
napcat: expect.objectContaining({
qqLoginStatus: 'online',
}),
}),
);
});
it('notifies manual re-login instead of auto-login when watchdog detects QQ login offline', async () => {
const toolsService = new ToolsService();
const account = {
connectStatus: 'online',
enabled: true,
id: 'account-1',
isDeleted: false,
lastError: null,
name: '主账号',
napcatLoginPasswordSecret: toolsService.encryptSecretText(
'qq-login-password',
'unit-secret',
),
selfId: '1914728559',
};
const binding = {
accountId: 'account-1',
bindStatus: 'bound',
containerId: 'container-1',
isDeleted: false,
isPrimary: true,
lastLoginAt: new Date('2026-06-10T12:00:00.000Z'),
};
const container = {
id: 'container-1',
isDeleted: false,
lastError: null,
name: 'kt-qqbot-napcat-1914728559',
status: 'running',
webuiPort: 6101,
};
const accountRepository = {
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([account]),
where: jest.fn().mockReturnThis(),
})),
update: jest.fn(),
};
const napcatContainerService = {
detectRuntimeOffline: jest
.fn()
.mockResolvedValue('NapCat 账号状态变更为离线'),
};
const systemNoticePublisher = {
publishSystemNotice: jest.fn().mockResolvedValue(undefined),
};
const service = createAccountServiceWithNapcatRuntime({
accountRepository,
bindingRepository: {
createQueryBuilder: jest.fn(() => ({
addOrderBy: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([binding]),
orderBy: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
},
configService: {
get: jest.fn((key: string) =>
key === 'QQBOT_ACCOUNT_SECRET_KEY' ? 'unit-secret' : '',
),
},
containerRepository: {
createQueryBuilder: jest.fn(() => ({
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([container]),
where: jest.fn().mockReturnThis(),
})),
},
containerService: napcatContainerService,
systemNoticePublisher,
toolsService,
});
const result = await service.runOfflineWatchdog();
expect(result).toEqual({ checked: 1 });
expect(accountRepository.update).toHaveBeenCalledWith(
{ selfId: '1914728559' },
expect.objectContaining({
lastError: 'NapCat 账号状态变更为离线',
qqLoginStatus: 'offline',
}),
);
expect(systemNoticePublisher.publishSystemNotice).toHaveBeenCalledWith(
expect.objectContaining({
content:
'NapCat 账号状态变更为离线\n请在 Admin 的 QQBot 账号页面手动点击「更新登录」重新登录。',
eventType: 'qqbot.account.offline',
severity: 'error',
title: 'QQBot 账号已下线1914728559',
}),
);
});
it('never reports auto-login cleanup failure from watchdog because watchdog no longer logs in', async () => {
const toolsService = new ToolsService();
const account = {
connectStatus: 'online',
enabled: true,
id: 'account-1',
isDeleted: false,
lastError: null,
name: '主账号',
napcatLoginPasswordSecret: toolsService.encryptSecretText(
'qq-login-password',
'unit-secret',
),
selfId: '1914728559',
};
const binding = {
accountId: 'account-1',
bindStatus: 'bound',
containerId: 'container-1',
isDeleted: false,
isPrimary: true,
lastLoginAt: new Date('2026-06-10T12:00:00.000Z'),
};
const container = {
id: 'container-1',
isDeleted: false,
lastError: null,
name: 'kt-qqbot-napcat-1914728559',
status: 'running',
webuiPort: 6101,
};
const accountRepository = {
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([account]),
where: jest.fn().mockReturnThis(),
})),
update: jest.fn(),
};
const napcatContainerService = {
detectRuntimeOffline: jest
.fn()
.mockResolvedValue('NapCat 账号状态变更为离线'),
};
const systemNoticePublisher = {
publishSystemNotice: jest.fn().mockResolvedValue(undefined),
};
const service = createAccountServiceWithNapcatRuntime({
accountRepository,
bindingRepository: {
createQueryBuilder: jest.fn(() => ({
addOrderBy: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([binding]),
orderBy: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
},
configService: {
get: jest.fn((key: string) =>
key === 'QQBOT_ACCOUNT_SECRET_KEY' ? 'unit-secret' : '',
),
},
containerRepository: {
createQueryBuilder: jest.fn(() => ({
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([container]),
where: jest.fn().mockReturnThis(),
})),
},
containerService: napcatContainerService,
systemNoticePublisher,
toolsService,
});
await service.runOfflineWatchdog();
expect(accountRepository.update).toHaveBeenCalledTimes(1);
expect(accountRepository.update).toHaveBeenCalledWith(
{ selfId: '1914728559' },
expect.objectContaining({
lastError: 'NapCat 账号状态变更为离线',
qqLoginStatus: 'offline',
}),
);
expect(systemNoticePublisher.publishSystemNotice).toHaveBeenCalledWith(
expect.objectContaining({
content:
'NapCat 账号状态变更为离线\n请在 Admin 的 QQBot 账号页面手动点击「更新登录」重新登录。',
eventType: 'qqbot.account.offline',
severity: 'error',
title: 'QQBot 账号已下线1914728559',
}),
);
expect(JSON.stringify(accountRepository.update.mock.calls)).not.toContain(
'NapCat 自动登录后运行态密码清理失败,请手动更新登录',
);
});
});