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) => { 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; }) => { 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, patch: Record, ) => { 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, patch: Record, ) => { 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; }) => { 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, patch: Record, ) => { 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 }) => { 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>, after: Array>, ): 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) => 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 自动登录后运行态密码清理失败,请手动更新登录', ); }); });