jest.mock('@/common', () => { const actualCommon = jest.requireActual('@/common'); return { ...actualCommon, ToolsService: actualCommon.ToolsService, ensureSnowflakeId: jest.fn(), setDictDecodeCache: jest.fn(), throwVbenError: (message: string) => { throw new Error(message); }, }; }); import { ConfigService } from '@nestjs/config'; import { ToolsService } from '@/common'; import { QqbotAccountService } from '@/qqbot/account/qqbot-account.service'; import { QqbotNapcatLoginService } from '@/qqbot/account/qqbot-napcat-login.service'; import { QqbotNapcatContainerService } from '@/qqbot/napcat/qqbot-napcat-container.service'; describe('QqbotNapcatLoginService', () => { const toolsService = new ToolsService(); const service = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, {} as QqbotAccountService, {} as QqbotNapcatContainerService, toolsService, ); beforeEach(() => { (service as any).sessions.clear(); (service as any).sessionEventLogs.clear(); (service as any).sessionEventListeners.clear(); jest.restoreAllMocks(); }); it('uses qrcode returned by NapCat refresh endpoint', async () => { (service as any).sessions.set('session-1', { containerId: 'container-1', containerName: 'napcat-1', createdAt: Date.now(), expiresAt: Date.now() + 60_000, id: 'session-1', mode: 'refresh', qrcode: 'old-qrcode', status: 'pending', webuiPort: 6100, }); jest .spyOn(service as any, 'getSessionContainer') .mockResolvedValue({ id: 'container-1' }); jest.spyOn(service as any, 'getLoginStatus').mockResolvedValue({ isLogin: false, }); jest .spyOn(service as any, 'callRefreshQrcode') .mockResolvedValue('new-qrcode-image'); const getQrcode = jest.spyOn(service as any, 'getQrcode'); const result = await service.refreshQrcode('session-1'); expect(result.qrcode).toBe('new-qrcode-image'); expect(getQrcode).not.toHaveBeenCalled(); }); it('reuses the existing account container when refreshing login', async () => { const account = { id: 'account-1', selfId: '10001', }; const existingContainer = { id: 'container-current', name: 'napcat-10001', }; const accountService = { findById: jest.fn().mockResolvedValue(account), findByIdWithNapcatLoginSecret: jest.fn().mockResolvedValue(account), getNapcatLoginPassword: jest.fn().mockReturnValue('qq-password'), }; const containerService = { prepareAccountContainer: jest.fn().mockResolvedValue(existingContainer), }; const refreshService = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, accountService as unknown as QqbotAccountService, containerService as unknown as QqbotNapcatContainerService, new ToolsService(), ); const startScan = jest .spyOn(refreshService as any, 'startScan') .mockResolvedValue({ sessionId: 'session-refresh' }); await refreshService.startRefresh('account-1'); expect(accountService.findByIdWithNapcatLoginSecret).toHaveBeenCalledWith( 'account-1', ); expect(accountService.getNapcatLoginPassword).toHaveBeenCalledWith(account); expect(containerService.prepareAccountContainer).toHaveBeenCalledWith( account, ); expect(startScan).toHaveBeenCalledWith( { accountId: 'account-1', expectedSelfId: '10001', forceRelogin: true, loginPassword: 'qq-password', mode: 'refresh', }, existingContainer, ); }); it('returns refresh login scan immediately while trying quick login in background', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-current', name: 'napcat-10001', }; const refreshService = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, {} as QqbotAccountService, {} as QqbotNapcatContainerService, new ToolsService(), ); jest .spyOn((refreshService as any).toolsService, 'sleep') .mockResolvedValue(undefined); jest .spyOn(refreshService as any, 'cleanupSessions') .mockResolvedValue(undefined); const prepareReloginQrcode = jest .spyOn(refreshService as any, 'prepareReloginQrcode') .mockResolvedValue(undefined); const result = await (refreshService as any).startScan( { accountId: 'account-1', expectedSelfId: '10001', forceRelogin: true, mode: 'refresh', }, container, ); expect(result.status).toBe('pending'); expect(result.qrcode).toBeUndefined(); expect(result.errorMessage).toBe('NapCat 正在尝试快速登录,请稍后'); expect(prepareReloginQrcode).toHaveBeenCalledWith( expect.objectContaining({ errorMessage: 'NapCat 正在尝试快速登录,请稍后', status: 'pending', }), container, undefined, undefined, ); }); it('uses NapCat -q quick login before generating qrcode for refresh login', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-quick', name: 'napcat-10001', }; const accountService = { ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), }; const containerService = { bindAccount: jest.fn().mockResolvedValue(undefined), ensureRuntimeLoginEnv: jest .fn() .mockResolvedValue({ changed: false, ok: true }), resetRuntimeLoginState: jest.fn().mockResolvedValue(true), restartRuntimeContainer: jest.fn().mockResolvedValue(true), }; const refreshService = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, accountService as unknown as QqbotAccountService, containerService as unknown as QqbotNapcatContainerService, new ToolsService(), ); const session = (refreshService as any).createSession({ accountId: 'account-1', container, expectedSelfId: '10001', mode: 'refresh', preparingRelogin: true, status: 'pending', }); (refreshService as any).sessions.set(session.id, session); jest .spyOn((refreshService as any).toolsService, 'sleep') .mockResolvedValue(undefined); jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ isLogin: true, }); jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ nickname: 'Kwi', online: true, uin: '10001', }); const refreshQrcode = jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); await (refreshService as any).prepareReloginQrcode(session, container); expect(containerService.ensureRuntimeLoginEnv).toHaveBeenCalledWith( container, { clearLoginPassword: true, selfId: '10001', }, ); expect(containerService.restartRuntimeContainer).toHaveBeenCalledWith( container, ); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); expect(refreshQrcode).not.toHaveBeenCalled(); expect(accountService.ensureScannedAccount).toHaveBeenCalledWith({ accountId: 'account-1', name: 'Kwi', selfId: '10001', }); expect(containerService.bindAccount).toHaveBeenCalledWith( 'account-1', 'container-quick', ); expect(session.status).toBe('success'); const events = (refreshService as any).sessionEventLogs.get(session.id); expect(events).toEqual( expect.arrayContaining([ expect.objectContaining({ step: 'quick-login-start' }), expect.objectContaining({ message: '快速登录成功', step: 'login-success', }), ]), ); }); it('does not complete quick login when runtime password env cleanup fails', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-quick-cleanup-failed', name: 'napcat-10001', }; const accountService = { ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), }; const containerService = { bindAccount: jest.fn().mockResolvedValue(undefined), ensureRuntimeLoginEnv: jest .fn() .mockResolvedValue({ changed: false, ok: false }), resetRuntimeLoginState: jest.fn().mockResolvedValue(true), restartRuntimeContainer: jest.fn().mockResolvedValue(true), }; const refreshService = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, accountService as unknown as QqbotAccountService, containerService as unknown as QqbotNapcatContainerService, new ToolsService(), ); const session = (refreshService as any).createSession({ accountId: 'account-1', container, expectedSelfId: '10001', mode: 'refresh', preparingRelogin: true, status: 'pending', }); (refreshService as any).sessions.set(session.id, session); jest .spyOn((refreshService as any).toolsService, 'sleep') .mockResolvedValue(undefined); jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ isLogin: true, }); jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ nickname: 'Kwi', online: true, uin: '10001', }); await (refreshService as any).prepareReloginQrcode(session, container); expect(accountService.ensureScannedAccount).not.toHaveBeenCalled(); expect(containerService.bindAccount).not.toHaveBeenCalled(); expect(session.status).toBe('error'); expect(session.errorMessage).toBe( 'NapCat 快速登录前运行态密码清理失败,请重试更新登录', ); const steps = ( (refreshService as any).sessionEventLogs.get(session.id) ?? [] ).map((event: { step: string }) => event.step); expect(steps).toContain('quick-login-cleanup'); expect(steps).not.toContain('login-success'); }); it('falls back to fresh qrcode when NapCat -q quick login fails', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-fallback', name: 'napcat-10001', }; const containerService = { ensureRuntimeLoginEnv: jest .fn() .mockResolvedValue({ changed: false, ok: true }), resetRuntimeLoginState: jest.fn().mockResolvedValue(true), restartRuntimeContainer: jest.fn().mockResolvedValue(true), }; const refreshService = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, {} as QqbotAccountService, containerService as unknown as QqbotNapcatContainerService, new ToolsService(), ); const session = (refreshService as any).createSession({ accountId: 'account-1', container, expectedSelfId: '10001', mode: 'refresh', preparingRelogin: true, status: 'pending', }); (refreshService as any).sessions.set(session.id, session); jest .spyOn((refreshService as any).toolsService, 'sleep') .mockResolvedValue(undefined); jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ isLogin: false, loginError: '快速登录未成功', }); jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); await (refreshService as any).prepareReloginQrcode(session, container); expect(containerService.restartRuntimeContainer).toHaveBeenCalledWith( container, ); expect(containerService.resetRuntimeLoginState).toHaveBeenCalledWith( container, expect.any(Function), ); expect( containerService.restartRuntimeContainer.mock.invocationCallOrder[0], ).toBeLessThan( containerService.resetRuntimeLoginState.mock.invocationCallOrder[0], ); expect(session.qrcode).toBe('fallback-qrcode'); const steps = ( (refreshService as any).sessionEventLogs.get(session.id) ?? [] ).map((event: { step: string }) => event.step); expect(steps.indexOf('quick-login-start')).toBeLessThan( steps.indexOf('quick-login-fallback'), ); expect(steps.indexOf('quick-login-fallback')).toBeLessThan( steps.indexOf('relogin-reset-start'), ); }); it('tries password login after quick login fails and before qrcode fallback', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-password', name: 'napcat-10001', }; const accountService = { ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), }; const containerService = { bindAccount: jest.fn().mockResolvedValue(undefined), ensureRuntimeLoginEnv: jest .fn() .mockResolvedValue({ changed: true, ok: true }), resetRuntimeLoginState: jest.fn().mockResolvedValue(true), restartRuntimeContainer: jest.fn().mockResolvedValue(true), }; const refreshService = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, accountService as unknown as QqbotAccountService, containerService as unknown as QqbotNapcatContainerService, new ToolsService(), ); const session = (refreshService as any).createSession({ accountId: 'account-1', container, expectedSelfId: '10001', mode: 'refresh', preparingRelogin: true, status: 'pending', }); (refreshService as any).sessions.set(session.id, session); jest .spyOn((refreshService as any).toolsService, 'sleep') .mockResolvedValue(undefined); jest .spyOn(refreshService as any, 'getLoginStatus') .mockResolvedValueOnce({ isLogin: false, loginError: '快速登录未找到历史会话', }) .mockResolvedValueOnce({ isLogin: true, }); jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ nickname: 'Kwi', online: true, uin: '10001', }); const refreshQrcode = jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); await (refreshService as any).prepareReloginQrcode( session, container, 'qq-password', ); expect(containerService.ensureRuntimeLoginEnv).toHaveBeenCalledWith( container, { clearLoginPassword: true, selfId: '10001', }, ); expect(containerService.ensureRuntimeLoginEnv).toHaveBeenCalledWith( container, { loginPassword: 'qq-password', selfId: '10001', }, ); expect(containerService.ensureRuntimeLoginEnv).toHaveBeenCalledWith( container, { clearLoginPassword: true, selfId: '10001', }, ); expect(containerService.ensureRuntimeLoginEnv).toHaveBeenNthCalledWith( 3, container, { clearLoginPassword: true, selfId: '10001', }, ); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); expect(refreshQrcode).not.toHaveBeenCalled(); expect(accountService.ensureScannedAccount).toHaveBeenCalledWith({ accountId: 'account-1', name: 'Kwi', selfId: '10001', }); expect(session.status).toBe('success'); const steps = ( (refreshService as any).sessionEventLogs.get(session.id) ?? [] ).map((event: { step: string }) => event.step); expect(steps).toEqual( expect.arrayContaining([ 'quick-login-start', 'quick-login-fallback', 'password-login-start', 'password-login-wait', 'login-success', ]), ); expect(steps.indexOf('quick-login-fallback')).toBeLessThan( steps.indexOf('password-login-start'), ); expect(steps.indexOf('password-login-start')).toBeLessThan( steps.indexOf('login-success'), ); }); it('uses password before qrcode without historical NapCat session', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', hasExistingPrimaryBinding: false, id: 'container-new', name: 'napcat-10001', }; const accountService = { ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), }; const containerService = { bindAccount: jest.fn().mockResolvedValue(undefined), ensureRuntimeLoginEnv: jest .fn() .mockResolvedValue({ changed: true, ok: true }), resetRuntimeLoginState: jest.fn().mockResolvedValue(true), restartRuntimeContainer: jest.fn().mockResolvedValue(true), }; const refreshService = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, accountService as unknown as QqbotAccountService, containerService as unknown as QqbotNapcatContainerService, new ToolsService(), ); const session = (refreshService as any).createSession({ accountId: 'account-1', container, expectedSelfId: '10001', mode: 'refresh', preparingRelogin: true, status: 'pending', }); (refreshService as any).sessions.set(session.id, session); jest .spyOn((refreshService as any).toolsService, 'sleep') .mockResolvedValue(undefined); jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ isLogin: true, }); jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ nickname: 'Kwi', online: true, uin: '10001', }); const refreshQrcode = jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); await (refreshService as any).prepareReloginQrcode( session, container, 'qq-password', false, ); expect(containerService.ensureRuntimeLoginEnv).toHaveBeenNthCalledWith( 1, container, { loginPassword: 'qq-password', selfId: '10001', }, ); expect(refreshQrcode).not.toHaveBeenCalled(); expect(session.status).toBe('success'); const steps = ( (refreshService as any).sessionEventLogs.get(session.id) ?? [] ).map((event: { step: string }) => event.step); expect(steps).not.toContain('quick-login-start'); expect(steps).toEqual( expect.arrayContaining(['password-login-start', 'login-success']), ); }); it('fails password refresh when runtime password env cleanup fails', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-password-cleanup-failed', name: 'napcat-10001', }; const accountService = { ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), }; const containerService = { bindAccount: jest.fn().mockResolvedValue(undefined), ensureRuntimeLoginEnv: jest .fn() .mockResolvedValueOnce({ changed: false, ok: true }) .mockResolvedValueOnce({ changed: true, ok: true }) .mockResolvedValueOnce({ changed: false, ok: false }), resetRuntimeLoginState: jest.fn().mockResolvedValue(true), restartRuntimeContainer: jest.fn().mockResolvedValue(true), }; const refreshService = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, accountService as unknown as QqbotAccountService, containerService as unknown as QqbotNapcatContainerService, new ToolsService(), ); const session = (refreshService as any).createSession({ accountId: 'account-1', container, expectedSelfId: '10001', mode: 'refresh', preparingRelogin: true, status: 'pending', }); (refreshService as any).sessions.set(session.id, session); jest .spyOn((refreshService as any).toolsService, 'sleep') .mockResolvedValue(undefined); jest .spyOn(refreshService as any, 'getLoginStatus') .mockResolvedValueOnce({ isLogin: false, loginError: '快速登录未找到历史会话', }) .mockResolvedValueOnce({ isLogin: true, }); jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ nickname: 'Kwi', online: true, uin: '10001', }); await (refreshService as any).prepareReloginQrcode( session, container, 'qq-password', ); expect(containerService.ensureRuntimeLoginEnv).toHaveBeenNthCalledWith( 3, container, { clearLoginPassword: true, selfId: '10001', }, ); expect(accountService.ensureScannedAccount).not.toHaveBeenCalled(); expect(containerService.bindAccount).not.toHaveBeenCalled(); expect(session.status).toBe('error'); expect(session.errorMessage).toBe( 'NapCat 密码登录已完成,但运行态密码清理失败,请重试更新登录', ); const steps = ( (refreshService as any).sessionEventLogs.get(session.id) ?? [] ).map((event: { step: string }) => event.step); expect(steps).toContain('password-env-cleanup'); expect(steps).not.toContain('login-success'); }); it('falls back to qrcode only after quick login and password login both fail', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-password-fallback', name: 'napcat-10001', }; const containerService = { ensureRuntimeLoginEnv: jest .fn() .mockResolvedValue({ changed: true, ok: true }), resetRuntimeLoginState: jest.fn().mockResolvedValue(true), restartRuntimeContainer: jest.fn().mockResolvedValue(true), }; const refreshService = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, {} as QqbotAccountService, containerService as unknown as QqbotNapcatContainerService, new ToolsService(), ); const session = (refreshService as any).createSession({ accountId: 'account-1', container, expectedSelfId: '10001', mode: 'refresh', preparingRelogin: true, status: 'pending', }); (refreshService as any).sessions.set(session.id, session); jest .spyOn((refreshService as any).toolsService, 'sleep') .mockResolvedValue(undefined); jest .spyOn(refreshService as any, 'getLoginStatus') .mockResolvedValueOnce({ isLogin: false, loginError: '快速登录未找到历史会话', }) .mockResolvedValueOnce({ isLogin: false, loginError: '密码登录未完成', }); jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); await (refreshService as any).prepareReloginQrcode( session, container, 'qq-password', ); expect(containerService.resetRuntimeLoginState).toHaveBeenCalledWith( container, expect.any(Function), ); expect(session.qrcode).toBe('fallback-qrcode'); const steps = ( (refreshService as any).sessionEventLogs.get(session.id) ?? [] ).map((event: { step: string }) => event.step); expect(steps.indexOf('quick-login-fallback')).toBeLessThan( steps.indexOf('password-login-start'), ); expect(steps.indexOf('password-login-fallback')).toBeLessThan( steps.indexOf('relogin-reset-start'), ); }); it('does not enter qrcode fallback when password env cleanup after failed password login fails', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-password-failed-cleanup-failed', name: 'napcat-10001', }; const containerService = { ensureRuntimeLoginEnv: jest .fn() .mockResolvedValueOnce({ changed: false, ok: true }) .mockResolvedValueOnce({ changed: true, ok: true }) .mockResolvedValueOnce({ changed: false, ok: false }), resetRuntimeLoginState: jest.fn().mockResolvedValue(true), restartRuntimeContainer: jest.fn().mockResolvedValue(true), }; const refreshService = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, {} as QqbotAccountService, containerService as unknown as QqbotNapcatContainerService, new ToolsService(), ); const session = (refreshService as any).createSession({ accountId: 'account-1', container, expectedSelfId: '10001', mode: 'refresh', preparingRelogin: true, status: 'pending', }); (refreshService as any).sessions.set(session.id, session); jest .spyOn((refreshService as any).toolsService, 'sleep') .mockResolvedValue(undefined); jest .spyOn(refreshService as any, 'getLoginStatus') .mockResolvedValueOnce({ isLogin: false, loginError: '快速登录未找到历史会话', }) .mockResolvedValueOnce({ isLogin: false, loginError: '密码登录未完成', }); const refreshQrcode = jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); await (refreshService as any).prepareReloginQrcode( session, container, 'qq-password', ); expect(containerService.ensureRuntimeLoginEnv).toHaveBeenNthCalledWith( 3, container, { clearLoginPassword: true, selfId: '10001', }, ); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); expect(refreshQrcode).not.toHaveBeenCalled(); expect(session.status).toBe('error'); expect(session.errorMessage).toBe( 'NapCat 密码登录未完成,且运行态密码清理失败,请重试更新登录', ); }); it('does not reset login state when quick login succeeds but binding fails', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-bind-error', name: 'napcat-10001', }; const accountService = { ensureScannedAccount: jest .fn() .mockRejectedValue(new Error('账号绑定写入失败')), }; const containerService = { bindAccount: jest.fn().mockResolvedValue(undefined), ensureRuntimeLoginEnv: jest .fn() .mockResolvedValue({ changed: false, ok: true }), resetRuntimeLoginState: jest.fn().mockResolvedValue(true), restartRuntimeContainer: jest.fn().mockResolvedValue(true), }; const refreshService = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, accountService as unknown as QqbotAccountService, containerService as unknown as QqbotNapcatContainerService, new ToolsService(), ); const session = (refreshService as any).createSession({ accountId: 'account-1', container, expectedSelfId: '10001', mode: 'refresh', preparingRelogin: true, status: 'pending', }); (refreshService as any).sessions.set(session.id, session); jest .spyOn((refreshService as any).toolsService, 'sleep') .mockResolvedValue(undefined); jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ isLogin: true, }); jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ nickname: 'Kwi', online: true, uin: '10001', }); const refreshQrcode = jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); await (refreshService as any).prepareReloginQrcode(session, container); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); expect(refreshQrcode).not.toHaveBeenCalled(); expect(session.status).toBe('error'); expect(session.qrcode).toBeUndefined(); expect(session.errorMessage).toBe('账号绑定写入失败'); const steps = ( (refreshService as any).sessionEventLogs.get(session.id) ?? [] ).map((event: { step: string }) => event.step); expect(steps).toContain('quick-login-start'); expect(steps).not.toContain('quick-login-fallback'); expect(steps).not.toContain('relogin-reset-start'); }); it('does not complete refresh login from stale status while relogin is preparing', async () => { (service as any).sessions.set('session-preparing', { containerId: 'container-preparing', containerName: 'napcat-preparing', createdAt: Date.now(), errorMessage: 'NapCat 正在尝试快速登录,请稍后', expiresAt: Date.now() + 60_000, id: 'session-preparing', mode: 'refresh', preparingRelogin: true, status: 'pending', webuiPort: 6106, }); const getLoginStatus = jest.spyOn(service as any, 'getLoginStatus'); const result = await service.status('session-preparing'); expect(result.status).toBe('pending'); expect(result.errorMessage).toBe('NapCat 正在尝试快速登录,请稍后'); expect(getLoginStatus).not.toHaveBeenCalled(); }); it('replays scan progress events to late SSE subscribers', () => { const session = { containerId: 'container-events', containerName: 'napcat-events', createdAt: Date.now(), expiresAt: Date.now() + 60_000, id: 'session-events', mode: 'refresh', status: 'pending', webuiPort: 6107, }; (service as any).sessions.set('session-events', session); (service as any).publishScanResultEvent( session, 'container-stop', 'processing', '正在停止 NapCat 容器', ); const events: any[] = []; const subscription = service .events('session-events') .subscribe((event) => events.push(event.data)); expect(events).toEqual([ expect.objectContaining({ message: '正在停止 NapCat 容器', status: 'processing', step: 'container-stop', }), ]); subscription.unsubscribe(); }); it('requires a qrcode different from the current one when refreshing qrcode', async () => { (service as any).sessions.set('session-refresh-qrcode', { containerId: 'container-3', containerName: 'napcat-3', createdAt: Date.now(), expiresAt: Date.now() + 60_000, id: 'session-refresh-qrcode', mode: 'refresh', qrcode: 'old-qrcode', status: 'pending', webuiPort: 6103, }); const container = { id: 'container-3' }; jest .spyOn(service as any, 'getSessionContainer') .mockResolvedValue(container); jest.spyOn(service as any, 'getLoginStatus').mockResolvedValue({ isLogin: false, qrcodeurl: 'old-qrcode', }); jest.spyOn(service as any, 'callRefreshQrcode').mockResolvedValue(''); jest.spyOn(service as any, 'getQrcode').mockResolvedValue('new-qrcode'); const result = await service.refreshQrcode('session-refresh-qrcode'); expect(result.qrcode).toBe('new-qrcode'); expect((service as any).getQrcode).toHaveBeenCalledWith(container, false, { requireFresh: true, staleQrcode: 'old-qrcode', }); }); it('keeps the refresh session pending when NapCat is still regenerating qrcode', async () => { (service as any).sessions.set('session-pending-qrcode', { containerId: 'container-pending', containerName: 'napcat-pending', createdAt: Date.now(), expiresAt: Date.now() + 60_000, id: 'session-pending-qrcode', mode: 'refresh', qrcode: 'old-qrcode', status: 'pending', webuiPort: 6105, }); jest .spyOn(service as any, 'getSessionContainer') .mockResolvedValue({ id: 'container-pending' }); jest.spyOn(service as any, 'getLoginStatus').mockResolvedValue({ isLogin: false, qrcodeurl: 'old-qrcode', }); jest .spyOn(service as any, 'callRefreshQrcode') .mockRejectedValue(new Error('NapCat 二维码仍未刷新')); const result = await service.refreshQrcode('session-pending-qrcode'); expect(result.status).toBe('pending'); expect(result.qrcode).toBeUndefined(); expect(result.errorMessage).toContain('正在重新生成二维码'); }); it('normalizes login status to offline when login info reports offline', async () => { jest .spyOn(service as any, 'postNapcat') .mockResolvedValueOnce({ isLogin: true, qrcodeurl: 'old-qrcode' }) .mockResolvedValueOnce({ online: false, uin: '10001' }); const result = await (service as any).getLoginStatus({ id: 'container-offline', }); expect(result).toEqual( expect.objectContaining({ isLogin: false, isOffline: true, }), ); }); it('restarts container and returns pending when refresh login status is effectively offline', async () => { const refreshService = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, {} as QqbotAccountService, {} as QqbotNapcatContainerService, new ToolsService(), ); jest .spyOn(refreshService as any, 'cleanupSessions') .mockResolvedValue(null); jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ isLogin: false, isOffline: true, loginError: 'NapCat 账号已离线', qrcodeurl: 'old-qrcode', }); const restartNapcatForLogin = jest .spyOn(refreshService as any, 'restartNapcatForLogin') .mockResolvedValue(undefined); const result = await (refreshService as any).startScan( { accountId: 'account-1', expectedSelfId: '10001', mode: 'refresh', }, { baseUrl: 'http://127.0.0.1:6105/', id: 'container-offline', name: 'napcat-10001', }, ); expect(result.status).toBe('pending'); expect(result.qrcode).toBeUndefined(); expect(result.errorMessage).toBe('NapCat 账号已离线'); expect(restartNapcatForLogin).toHaveBeenCalledWith( expect.objectContaining({ id: 'container-offline' }), { waitForReady: false }, ); }); it('restarts the existing NapCat container before qrcode refresh when account is kicked offline', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-3', name: 'napcat-10001', }; const containerService = { restartRuntimeContainer: jest.fn().mockResolvedValue(true), }; const refreshService = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, {} as QqbotAccountService, containerService as unknown as QqbotNapcatContainerService, new ToolsService(), ); jest .spyOn((refreshService as any).toolsService, 'sleep') .mockResolvedValue(undefined); jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ isLogin: false, qrcodeurl: 'new-status-qrcode', }); jest .spyOn(refreshService as any, 'callRefreshQrcode') .mockResolvedValue(''); jest .spyOn(refreshService as any, 'getQrcode') .mockResolvedValue('new-qrcode'); const result = await (refreshService as any).refreshOrGetQrcode( container, true, { fallbackStatus: { isLogin: false, isOffline: true, loginError: '您的账号已在另一台终端登录', qrcodeurl: 'old-qrcode', }, }, ); expect(result).toBe('new-qrcode'); expect(containerService.restartRuntimeContainer).toHaveBeenCalledWith( container, ); expect((refreshService as any).getQrcode).toHaveBeenCalledWith( container, true, { requireFresh: true, staleQrcode: 'old-qrcode', }, ); }); it('retries while NapCat still exposes the stale qrcode', async () => { jest .spyOn((service as any).toolsService, 'sleep') .mockResolvedValue(undefined); jest .spyOn(service as any, 'postNapcat') .mockResolvedValueOnce({ qrcode: 'old-qrcode' }) .mockResolvedValueOnce({ qrcode: 'new-qrcode' }); const result = await (service as any).getQrcode( { id: 'container-4' }, true, { requireFresh: true, staleQrcode: 'old-qrcode', }, ); expect(result).toBe('new-qrcode'); expect((service as any).postNapcat).toHaveBeenCalledTimes(2); }); it('does not replace current qrcode with expired status qrcode', async () => { (service as any).sessions.set('session-2', { containerId: 'container-2', containerName: 'napcat-2', createdAt: Date.now(), expiresAt: Date.now() + 60_000, id: 'session-2', mode: 'refresh', qrcode: 'current-qrcode', status: 'pending', webuiPort: 6101, }); jest .spyOn(service as any, 'getSessionContainer') .mockResolvedValue({ id: 'container-2' }); jest.spyOn(service as any, 'getLoginStatus').mockResolvedValue({ isLogin: false, loginError: '二维码已过期', qrcodeurl: 'expired-qrcode', }); const result = await service.status('session-2'); expect(result.qrcode).toBe('current-qrcode'); expect(result.errorMessage).toBe('二维码已过期'); }); });