jest.mock('@/common', () => { const actualCommon = jest.requireActual('@/common'); return { ...actualCommon, ToolsService: actualCommon.ToolsService, ensureSnowflakeId: jest.fn(), setDictDecodeCache: jest.fn(), /** * 执行 测试回调。 * @param message - message 输入;驱动 `Error()` 的 测试步骤。 */ throwVbenError: (message: string) => { throw new Error(message); }, }; }); import { ConfigService } from '@nestjs/config'; import { ToolsService } from '@/common'; import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service'; import { QqbotNapcatLoginService } from '@/modules/qqbot/napcat/application/login/qqbot-napcat-login.service'; import { QqbotNapcatContainerService } from '@/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service'; import { NapcatLoginSession } from '@/modules/qqbot/napcat/infrastructure/persistence/napcat-login-session.entity'; import { NapcatLoginStateStoreService } from '@/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service'; describe('QqbotNapcatLoginService', () => { /** * 为更新登录测试模拟 NapCat WebUI quick/password 登录接口。 * @param target - 被测登录服务;其私有 postNapcat 会被替换为可控实现。 * @param options - quick/password 分支的返回或错误,用来驱动目标状态机路径。 * @returns Jest spy,供用例继续断言 WebUI 请求体。 */ const mockWebuiLoginPost = ( target: QqbotNapcatLoginService, options: { passwordError?: string; passwordResult?: null | Record; quickError?: string; quickResult?: null | Record; } = {}, ) => jest .spyOn(target as any, 'postNapcat') .mockImplementation(async (_container: unknown, path: string) => { if (path === '/api/QQLogin/SetQuickLogin') { if (options.quickError) throw new Error(options.quickError); return options.quickResult ?? null; } if (path === '/api/QQLogin/PasswordLogin') { if (options.passwordError) throw new Error(options.passwordError); return options.passwordResult ?? null; } return null; }); const toolsService = new ToolsService(); /** * 创建用于登录会话持久化断言的 TypeORM Repository 替身。 * @returns 带内存 rows 的最小 Repository;按 sessionKey 模拟 update/save 行为。 */ const createLoginSessionRepository = () => { const rows: NapcatLoginSession[] = []; return { create: jest.fn( (input: Partial) => ({ ...input }) as NapcatLoginSession, ), findOne: jest.fn(async ({ where }: { where: Record }) => { return ( rows.find((row) => Object.entries(where).every(([key, value]) => row[key] === value), ) || null ); }), rows, save: jest.fn(async (input: NapcatLoginSession) => { const index = rows.findIndex( (row) => (input.id && row.id === input.id) || (input.sessionKey && row.sessionKey === input.sessionKey), ); if (index >= 0) { rows[index] = { ...rows[index], ...input } as NapcatLoginSession; return rows[index]; } rows.push(input); return input; }), update: jest.fn( async ( where: Record, input: Partial, ) => { const row = rows.find((item) => Object.entries(where).every(([key, value]) => item[key] === value), ); if (row) Object.assign(row, input); return { affected: row ? 1 : 0 }; }, ), }; }; 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, 'qq-password', ); 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('reuses an active refresh session instead of starting repeated relogin during SSE retries', async () => { const account = { id: 'account-1', selfId: '10001', }; const existingContainer = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-current', name: 'napcat-10001', }; const accountService = { 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 prepareReloginQrcode = jest .spyOn(refreshService as any, 'prepareReloginQrcode') .mockResolvedValue(undefined); const first = await refreshService.startRefresh('account-1'); const second = await refreshService.startRefresh('account-1'); expect(second.sessionId).toBe(first.sessionId); expect(accountService.findByIdWithNapcatLoginSecret).toHaveBeenCalledTimes( 1, ); expect(containerService.prepareAccountContainer).toHaveBeenCalledTimes(1); expect(prepareReloginQrcode).toHaveBeenCalledTimes(1); }); it('does not return a stale qrcode when reusing an active refresh session', async () => { const refreshService = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, {} as QqbotAccountService, {} as QqbotNapcatContainerService, new ToolsService(), ); (refreshService as any).sessions.set('session-active-stale-qrcode', { accountId: 'account-1', containerId: 'container-1', containerName: 'napcat-1', createdAt: Date.now(), expiresAt: Date.now() + 60_000, id: 'session-active-stale-qrcode', mode: 'refresh', qrcode: 'old-qrcode', status: 'pending', webuiPort: 6101, }); const refreshQrcode = jest .spyOn(refreshService, 'refreshQrcode') .mockResolvedValue({ errorMessage: 'NapCat 正在重新生成二维码,请稍后刷新或等待自动更新', mode: 'refresh', qrcode: undefined, sessionId: 'session-active-stale-qrcode', status: 'pending', }); const result = await refreshService.startRefresh('account-1'); expect(result.sessionId).toBe('session-active-stale-qrcode'); expect(result.qrcode).toBeUndefined(); expect(result.errorMessage).toContain('正在重新生成二维码'); expect(refreshQrcode).toHaveBeenCalledWith('session-active-stale-qrcode'); }); it('shares an in-flight refresh task before the first session is created', async () => { const account = { id: 'account-1', selfId: '10001', }; const existingContainer = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-current', name: 'napcat-10001', }; const accountService = { findByIdWithNapcatLoginSecret: jest.fn().mockResolvedValue(account), getNapcatLoginPassword: jest.fn().mockReturnValue('qq-password'), }; let resolveContainer: (container: typeof existingContainer) => void = () => undefined; const containerPromise = new Promise( (resolve) => { resolveContainer = resolve; }, ); const containerService = { prepareAccountContainer: jest.fn().mockReturnValue(containerPromise), }; const refreshService = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, accountService as unknown as QqbotAccountService, containerService as unknown as QqbotNapcatContainerService, new ToolsService(), ); const prepareReloginQrcode = jest .spyOn(refreshService as any, 'prepareReloginQrcode') .mockResolvedValue(undefined); const firstTask = refreshService.startRefresh('account-1'); const secondTask = refreshService.startRefresh('account-1'); resolveContainer(existingContainer); const [first, second] = await Promise.all([firstTask, secondTask]); expect(second.sessionId).toBe(first.sessionId); expect(accountService.findByIdWithNapcatLoginSecret).toHaveBeenCalledTimes( 1, ); expect(containerService.prepareAccountContainer).toHaveBeenCalledTimes(1); expect(prepareReloginQrcode).toHaveBeenCalledTimes(1); }); it('keeps refresh login session alive while relogin is still preparing', async () => { const containerService = { removeUnboundContainer: jest.fn().mockResolvedValue(false), }; const refreshService = new QqbotNapcatLoginService( { get: jest.fn((key: string) => key === 'NAPCAT_LOGIN_QR_EXPIRE_MS' ? '120000' : '', ), } as unknown as ConfigService, {} as QqbotAccountService, containerService as unknown as QqbotNapcatContainerService, new ToolsService(), ); const session = (refreshService as any).createSession({ accountId: 'account-1', container: { id: 'container-ttl', name: 'napcat-10001', }, expectedSelfId: '10001', mode: 'refresh', preparingRelogin: true, status: 'pending', }); session.errorMessage = 'NapCat 正在尝试密码登录,请稍后'; session.expiresAt = Date.now() - 1000; (refreshService as any).sessions.set(session.id, session); const result = await refreshService.status(session.id); expect(result.status).toBe('pending'); expect(result.errorMessage).toBe('NapCat 正在尝试密码登录,请稍后'); expect(result.expiresAt).toBeGreaterThan(Date.now()); expect(containerService.removeUnboundContainer).not.toHaveBeenCalled(); expect((refreshService as any).sessions.has(session.id)).toBe(true); }); it('uses a longer TTL for human captcha and new-device verification states', () => { const now = new Date('2026-06-19T04:10:00+08:00').getTime(); jest.spyOn(Date, 'now').mockReturnValue(now); const refreshService = new QqbotNapcatLoginService( { get: jest.fn((key: string) => { if (key === 'NAPCAT_LOGIN_QR_EXPIRE_MS') return '120000'; if (key === 'NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS') return '900000'; return ''; }), } as unknown as ConfigService, {} as QqbotAccountService, {} as QqbotNapcatContainerService, new ToolsService(), ); const qrcodeSession = (refreshService as any).createSession({ container: { id: 'container-qr', name: 'napcat-qr' }, mode: 'refresh', status: 'pending', }); const captchaSession = (refreshService as any).createSession({ container: { id: 'container-captcha', name: 'napcat-captcha' }, mode: 'refresh', status: 'pending', }); const newDeviceSession = (refreshService as any).createSession({ container: { id: 'container-device', name: 'napcat-device' }, mode: 'refresh', status: 'pending', }); const captcha = (refreshService as any).keepPasswordCaptchaPending( captchaSession, 'https://ti.qq.com/safe/tools/captcha/sms-verify-login', ); const newDevice = (refreshService as any).keepNewDevicePending( newDeviceSession, 'qr-pending', '新设备二维码待扫码', 'new-device-qrcode-ready', ); expect(qrcodeSession.expiresAt).toBe(now + 120_000); expect(captcha.expiresAt).toBe(now + 900_000); expect(newDevice.expiresAt).toBe(now + 900_000); }); it('recovers stale refresh preparation left by a restarted API pod', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-stale-preparing', name: 'napcat-10001', }; const containerService = { findRuntimeById: jest.fn().mockResolvedValue(container), removeUnboundContainer: jest.fn().mockResolvedValue(false), }; const refreshService = new QqbotNapcatLoginService( { get: jest.fn((key: string) => { if (key === 'NAPCAT_LOGIN_QR_EXPIRE_MS') return '120000'; if (key === 'QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS') return '1000'; if (key === 'QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS') return '1000'; if (key === 'NAPCAT_WEBUI_RESTART_DELAY_MS') return '100'; if (key === 'NAPCAT_WEBUI_TIMEOUT_MS') return '100'; return ''; }), } 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', }); session.errorMessage = 'NapCat 正在尝试密码登录,请稍后'; session.expiresAt = Date.now() + 60_000; session.lastRestartedAt = Date.now() - 10_000; (refreshService as any).sessions.set(session.id, session); const getLoginStatus = jest .spyOn(refreshService as any, 'getLoginStatus') .mockResolvedValue({ isLogin: false, qrcodeurl: 'fresh-qrcode-after-restart', }); jest .spyOn(refreshService as any, 'getQrcode') .mockResolvedValue('fresh-qrcode-after-restart'); const result = await refreshService.status(session.id); expect(getLoginStatus).toHaveBeenCalledWith(container); expect(result.status).toBe('pending'); expect(result.qrcode).toBe('fresh-qrcode-after-restart'); expect( (refreshService as any).sessions.get(session.id).preparingRelogin, ).toBe(false); }); it('uses runtime logs to recover captcha url when status only says captcha required', async () => { const captchaUrl = 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001'; const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-status-captcha', name: 'napcat-10001', }; const containerService = { detectRuntimeCaptchaUrl: jest.fn().mockResolvedValue(captchaUrl), findRuntimeById: jest.fn().mockResolvedValue(container), removeUnboundContainer: jest.fn().mockResolvedValue(false), }; 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', status: 'pending', }); session.lastRestartedAt = Date.now() - 10_000; (refreshService as any).sessions.set(session.id, session); jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ isLogin: false, loginError: '密码回退需要验证码,请在 WebUi 中继续完成验证', }); const result = await refreshService.status(session.id); expect(containerService.detectRuntimeCaptchaUrl).toHaveBeenCalledWith( container, session.lastRestartedAt, ); expect(result.captchaUrl).toBe(captchaUrl); expect(result.errorMessage).toContain('密码登录需要完成 QQ 安全验证'); }); it('keeps existing password captcha pending when NapCat still reports captcha without url', async () => { const captchaUrl = 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001'; const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-captcha-status', name: 'napcat-10001', }; const containerService = { ensureRuntimeLoginEnv: jest.fn().mockResolvedValue({ changed: true, ok: true, }), findRuntimeById: jest.fn().mockResolvedValue(container), removeUnboundContainer: jest.fn().mockResolvedValue(false), }; 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', status: 'pending', }); session.captchaUrl = captchaUrl; session.passwordMd5 = '0123456789abcdef0123456789abcdef'; (refreshService as any).sessions.set(session.id, session); jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ isLogin: false, loginError: '密码回退需要验证码,请在 WebUi 中继续完成验证', }); const result = await refreshService.status(session.id); expect(result.status).toBe('pending'); expect(result.captchaUrl).toBe(captchaUrl); expect(result.errorMessage).toContain('密码登录需要完成 QQ 安全验证'); expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); }); it('does not keep existing password captcha pending when safety verification fails', async () => { const captchaUrl = 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001'; const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-captcha-failed-status', name: 'napcat-10001', }; const containerService = { ensureRuntimeLoginEnv: jest.fn().mockResolvedValue({ changed: true, ok: true, }), findRuntimeById: jest.fn().mockResolvedValue(container), removeUnboundContainer: jest.fn().mockResolvedValue(false), }; 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', status: 'pending', }); session.captchaUrl = captchaUrl; session.passwordMd5 = '0123456789abcdef0123456789abcdef'; (refreshService as any).sessions.set(session.id, session); jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ isLogin: false, loginError: '安全验证失败,请重新登录', }); const result = await refreshService.status(session.id); expect(result.status).toBe('error'); expect(result.captchaUrl).toBeUndefined(); expect(result.errorMessage).toBe('安全验证失败,请重新登录'); expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); }); it('uses NapCat WebUI 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 postNapcat = jest .spyOn(refreshService as any, 'postNapcat') .mockResolvedValue(null); const refreshQrcode = jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); await (refreshService as any).prepareReloginQrcode(session, container); expect(postNapcat).toHaveBeenCalledWith( container, '/api/QQLogin/SetQuickLogin', { uin: '10001' }, ); expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); 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', '10001', ); 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('completes quick login without runtime password env cleanup', 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', }); mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode(session, container); expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); expect(accountService.ensureScannedAccount).toHaveBeenCalledWith({ accountId: 'account-1', name: 'Kwi', selfId: '10001', }); expect(containerService.bindAccount).toHaveBeenCalledWith( 'account-1', 'container-quick-cleanup-failed', '10001', ); 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-cleanup'); expect(steps).toContain('login-success'); }); it('falls back to fresh qrcode without Docker reset when WebUI 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, 'postNapcat') .mockRejectedValue(new Error('快速登录未成功')); jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); await (refreshService as any).prepareReloginQrcode(session, container); expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); 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('qrcode-fetch'), ); }); it('returns create login scan before remote container startup finishes', async () => { const container = { baseUrl: 'http://127.0.0.1:6104/', id: 'container-create', name: 'napcat-create', webuiPort: 6104, }; const never = new Promise(() => undefined); const containerService = { prepareCreateContainer: jest.fn().mockReturnValue(never), reserveCreateContainer: jest.fn().mockResolvedValue(container), startCreateContainer: jest.fn().mockReturnValue(never), }; const createService = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, {} as QqbotAccountService, containerService as unknown as QqbotNapcatContainerService, new ToolsService(), ); const result = await Promise.race([ createService.startCreate(), new Promise((resolve) => { setTimeout(() => resolve({ timedOut: true }), 20); }), ]); expect(result).toEqual( expect.objectContaining({ containerId: 'container-create', errorMessage: 'NapCat 正在创建登录容器,请稍后', mode: 'create', qrcode: undefined, status: 'pending', }), ); expect(containerService.prepareCreateContainer).not.toHaveBeenCalled(); expect(containerService.reserveCreateContainer).toHaveBeenCalledTimes(1); expect(containerService.startCreateContainer).toHaveBeenCalledWith( container, ); }); it('keeps create login pending while the remote container is still starting', async () => { const container = { baseUrl: 'http://127.0.0.1:6104/', id: 'container-create', name: 'napcat-create', webuiPort: 6104, }; const never = new Promise(() => undefined); const containerService = { reserveCreateContainer: jest.fn().mockResolvedValue(container), startCreateContainer: jest.fn().mockReturnValue(never), }; const createService = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, {} as QqbotAccountService, containerService as unknown as QqbotNapcatContainerService, new ToolsService(), ); const getSessionContainer = jest.spyOn( createService as any, 'getSessionContainer', ); const result = await createService.startCreate(); const status = await createService.status(result.sessionId || ''); expect(status).toEqual( expect.objectContaining({ errorMessage: 'NapCat 正在创建登录容器,请稍后', mode: 'create', qrcode: undefined, status: 'pending', }), ); expect(getSessionContainer).not.toHaveBeenCalled(); }); it('recovers stale create login preparation after the background task was lost', async () => { const container = { baseUrl: 'http://127.0.0.1:6105/', id: 'container-create-stale', name: 'napcat-create-stale', webuiPort: 6105, }; const never = new Promise(() => undefined); const containerService = { findRuntimeById: jest.fn().mockResolvedValue(container), startCreateContainer: jest.fn().mockReturnValue(never), }; const createService = new QqbotNapcatLoginService( { get: jest.fn((key: string) => { if (key === 'QQBOT_NAPCAT_CREATE_PREPARING_STALE_MS') return '1'; return undefined; }), } as unknown as ConfigService, {} as QqbotAccountService, containerService as unknown as QqbotNapcatContainerService, new ToolsService(), ); const session = (createService as any).createSession({ container, mode: 'create', preparingContainer: true, status: 'pending', }); const old = Date.now() - 10_000; session.createdAt = old; session.errorMessage = 'NapCat 正在创建登录容器,请稍后'; session.expiresAt = Date.now() + 60_000; session.lastRestartedAt = old; (createService as any).sessions.set(session.id, session); const status = await createService.status(session.id); await Promise.resolve(); await Promise.resolve(); expect(status).toEqual( expect.objectContaining({ errorMessage: 'NapCat 创建任务已恢复,继续创建登录容器', mode: 'create', status: 'pending', }), ); expect(containerService.findRuntimeById).toHaveBeenCalledWith( 'container-create-stale', ); expect(containerService.startCreateContainer).toHaveBeenCalledWith( container, ); }); it('confirms real login status when quick login reports the account is already logged in', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-quick-already-online', name: 'napcat-10001', }; const accountService = { ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), }; const containerService = { bindAccount: jest.fn().mockResolvedValue(undefined), ensureRuntimeLoginEnv: jest.fn(), resetRuntimeLoginState: jest.fn(), restartRuntimeContainer: jest.fn(), }; 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); mockWebuiLoginPost(refreshService, { quickError: 'QQ Is Logined', }); const getLoginStatus = 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(getLoginStatus).toHaveBeenCalledWith(container, true); expect(refreshQrcode).not.toHaveBeenCalled(); expect(accountService.ensureScannedAccount).toHaveBeenCalledWith({ accountId: 'account-1', name: 'Kwi', selfId: '10001', }); expect(containerService.bindAccount).toHaveBeenCalledWith( 'account-1', 'container-quick-already-online', '10001', ); 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: 'NapCat 已登录,已确认真实在线状态', step: 'login-success', }), ]), ); }); it('keeps binding errors terminal after already-logged quick status is confirmed online', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-quick-already-bind-error', name: 'napcat-10001', }; const accountService = { ensureScannedAccount: jest .fn() .mockRejectedValue(new Error('账号绑定写入失败')), }; const containerService = { bindAccount: jest.fn().mockResolvedValue(undefined), resetRuntimeLoginState: jest.fn(), restartRuntimeContainer: jest.fn(), }; 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); mockWebuiLoginPost(refreshService, { quickError: 'QQ Is Logined', }); 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(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-status-check'); expect(steps).not.toContain('quick-login-fallback'); expect(steps).toContain('relogin-error'); }); it('does not rebuild even when the refresh session already has a rebuild count', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-rebuild-budget', 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, runtimeRebuildCount: 1, 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: '快速登录未成功', }); mockWebuiLoginPost(refreshService, { quickError: '快速登录未成功', }); jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('budget-qrcode'); await (refreshService as any).prepareReloginQrcode(session, container); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); expect(session.status).toBe('pending'); expect(session.qrcode).toBe('budget-qrcode'); }); it('refreshes qrcode through WebUI when the source Docker container is online but QQ is offline', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-online-source', name: 'napcat-10001', sourceContainerOnline: true, }; const containerService = { ensureRuntimeLoginEnv: jest .fn() .mockResolvedValue({ changed: false, ok: true }), findRuntimeById: jest.fn().mockResolvedValue(container), resetRuntimeLoginState: jest.fn().mockResolvedValue(true), restartRuntimeContainer: jest.fn().mockResolvedValue(true), }; const accountService = { ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), }; 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: false, sourceContainerOnline: true, status: 'pending', }); (refreshService as any).sessions.set(session.id, session); jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ isOffline: true, isLogin: false, loginError: 'NapCat 账号状态变更为离线', }); const refreshOrGetQrcode = jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('online-source-qrcode'); const result = await refreshService.refreshQrcode(session.id); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); expect(refreshOrGetQrcode).toHaveBeenCalledWith(container, false, { fallbackStatus: expect.objectContaining({ isOffline: true }), requireFresh: true, staleQrcode: undefined, }); expect(result.status).toBe('pending'); expect(result.qrcode).toBe('online-source-qrcode'); }); it('does not perform runtime env rebuild inside the refresh session', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-env-budget', name: 'napcat-10001', }; const containerService = { bindAccount: jest.fn().mockResolvedValue(undefined), ensureRuntimeLoginEnv: jest.fn(), resetRuntimeLoginState: jest.fn().mockResolvedValue(true), restartRuntimeContainer: jest.fn().mockResolvedValue(true), runtimeLoginEnvMatches: jest.fn().mockResolvedValue(false), }; const accountService = { ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), }; 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, runtimeRebuildCount: 1, status: 'pending', }); (refreshService as any).sessions.set(session.id, session); 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', }); mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode( session, container, 'qq-password', ); expect(containerService.runtimeLoginEnvMatches).not.toHaveBeenCalled(); expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); expect(accountService.ensureScannedAccount).toHaveBeenCalledWith({ accountId: 'account-1', name: 'Kwi', selfId: '10001', }); expect(session.status).toBe('success'); }); it('prepares qrcode through WebUI when the source Docker container is online', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-online-env', name: 'napcat-10001', sourceContainerOnline: true, }; const containerService = { ensureRuntimeLoginEnv: jest.fn(), resetRuntimeLoginState: jest.fn().mockResolvedValue(true), restartRuntimeContainer: jest.fn().mockResolvedValue(true), runtimeLoginEnvMatches: jest.fn().mockResolvedValue(false), }; const accountService = { ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), }; 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, sourceContainerOnline: true, status: 'pending', }); (refreshService as any).sessions.set(session.id, session); jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ isLogin: false, loginError: 'Docker 容器在线但 QQ 账号离线', }); jest .spyOn(refreshService as any, 'postNapcat') .mockRejectedValue(new Error('快速登录未成功')); jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('online-source-qrcode'); await (refreshService as any).prepareReloginQrcode(session, container); expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); expect(containerService.runtimeLoginEnvMatches).not.toHaveBeenCalled(); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); expect(session.status).toBe('pending'); expect(session.qrcode).toBe('online-source-qrcode'); }); 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: 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); const postNapcat = jest .spyOn(refreshService as any, 'postNapcat') .mockImplementation(async (_container: unknown, path: string) => { if (path === '/api/QQLogin/SetQuickLogin') { throw new Error('快速登录未找到历史会话'); } if (path === '/api/QQLogin/PasswordLogin') return null; return null; }); jest.spyOn(refreshService as any, 'getLoginStatus').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(postNapcat).toHaveBeenCalledWith( container, '/api/QQLogin/SetQuickLogin', { uin: '10001' }, ); expect(postNapcat).toHaveBeenCalledWith( container, '/api/QQLogin/PasswordLogin', { passwordMd5: '7fe1f9ae1130b64e9ca1441492c382c0', uin: '10001', }, ); expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); 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('waits for password login to finish instead of falling back on the first pending status', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-password-wait', 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((key: string) => { const values: Record = { QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS: '1', QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS: '3', QQBOT_NAPCAT_QUICK_LOGIN_WAIT_MS: '1', }; return values[key] || ''; }), } 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: false, loginError: '密码登录处理中', }) .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'); mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode( session, container, 'qq-password', ); expect(refreshQrcode).not.toHaveBeenCalled(); expect(accountService.ensureScannedAccount).toHaveBeenCalledWith({ accountId: 'account-1', name: 'Kwi', selfId: '10001', }); expect(session.status).toBe('success'); }); it('keeps password login pending when QQ requires captcha verification from login status', async () => { const captchaUrl = 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001'; const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-password-captcha', 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((key: string) => { const values: Record = { QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS: '1', QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS: '1', QQBOT_NAPCAT_QUICK_LOGIN_WAIT_MS: '1', }; return values[key] || ''; }), } 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: `需要验证码, proofWaterUrl: ${captchaUrl}`, }); const refreshQrcode = jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode( session, container, 'qq-password', ); expect(refreshQrcode).not.toHaveBeenCalled(); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); expect(session.status).toBe('pending'); expect(session.captchaUrl).toBe(captchaUrl); expect(session.preparingRelogin).toBe(false); expect(session.errorMessage).toContain('需要完成 QQ 安全验证'); const steps = ( (refreshService as any).sessionEventLogs.get(session.id) ?? [] ).map((event: { step: string }) => event.step); expect(steps).toContain('password-login-captcha'); expect(steps).not.toContain('relogin-reset-start'); }); it('keeps password login pending when QQ captcha URL is only in runtime logs', async () => { const captchaUrl = 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001'; const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-password-captcha-log', name: 'napcat-10001', }; const containerService = { detectRuntimeCaptchaUrl: jest.fn().mockResolvedValue(captchaUrl), 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((key: string) => { const values: Record = { QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS: '1', QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS: '1', QQBOT_NAPCAT_QUICK_LOGIN_WAIT_MS: '1', }; return values[key] || ''; }), } 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'); mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode( session, container, 'qq-password', ); expect(containerService.detectRuntimeCaptchaUrl).toHaveBeenCalledWith( container, expect.any(Number), ); expect(refreshQrcode).not.toHaveBeenCalled(); expect(session.status).toBe('pending'); expect(session.captchaUrl).toBe(captchaUrl); }); it('waits briefly for captcha URL when captcha status arrives before runtime log URL', async () => { const captchaUrl = 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001'; const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-password-captcha-delayed-log', name: 'napcat-10001', }; let captchaLogVisible = false; let captchaStatusSeen = false; const containerService = { detectRuntimeCaptchaUrl: jest .fn() .mockImplementation(async () => captchaLogVisible ? captchaUrl : null, ), 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((key: string) => { const values: Record = { QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS: '1', QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS: '1', QQBOT_NAPCAT_QUICK_LOGIN_WAIT_MS: '1', }; return values[key] || ''; }), } 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') .mockImplementation(async () => { if (captchaStatusSeen) captchaLogVisible = true; }); jest .spyOn(refreshService as any, 'getLoginStatus') .mockResolvedValueOnce({ isLogin: false, loginError: '快速登录未找到历史会话', }) .mockImplementationOnce(async () => { captchaStatusSeen = true; return { isLogin: false, loginError: '密码回退需要验证码,请在 WebUi 中继续完成验证', }; }); const refreshQrcode = jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode( session, container, 'qq-password', ); expect(refreshQrcode).not.toHaveBeenCalled(); expect(session.status).toBe('pending'); expect(session.captchaUrl).toBe(captchaUrl); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); }); it('anchors password captcha log lookup before runtime env rebuild', async () => { const captchaUrl = 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001'; const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-password-log-window', name: 'napcat-10001', }; const passwordLogSinceMs = 1000; const envRebuildStartedAt = 5000; let envRebuildStarted = false; const containerService = { ensureRuntimeLoginEnv: jest.fn().mockImplementation(async () => { envRebuildStarted = true; return { changed: true, ok: true }; }), restartRuntimeContainer: jest.fn().mockResolvedValue(true), }; const refreshService = new QqbotNapcatLoginService( { get: jest.fn((key: string) => { const values: Record = { QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS: '1', QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS: '1', }; return values[key] || ''; }), } 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', }); jest .spyOn(Date, 'now') .mockImplementation(() => envRebuildStarted ? envRebuildStartedAt : passwordLogSinceMs, ); jest .spyOn(refreshService as any, 'waitForPasswordLoginStatus') .mockResolvedValue({ isLogin: false, loginError: '密码回退需要验证码,请在 WebUi 中继续完成验证', }); jest .spyOn(refreshService as any, 'resolvePasswordCaptchaUrl') .mockResolvedValue(captchaUrl); mockWebuiLoginPost(refreshService); const result = await (refreshService as any).tryPasswordRelogin( session, container, 'qq-password', ); expect(result).toBe(true); expect( (refreshService as any).waitForPasswordLoginStatus, ).toHaveBeenCalledWith(container, passwordLogSinceMs); expect( (refreshService as any).resolvePasswordCaptchaUrl, ).toHaveBeenCalledWith( container, expect.objectContaining({ isLogin: false }), passwordLogSinceMs, ); expect(session.lastRestartedAt).toBe(passwordLogSinceMs); expect(session.captchaUrl).toBe(captchaUrl); }); it('does not keep stale captcha from tail logs when current password status is processing', async () => { const staleCaptchaUrl = 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001'; const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-password-stale-captcha', name: 'napcat-10001', }; const containerService = { detectRuntimeCaptchaUrl: jest .fn() .mockImplementation(async (_runtime: unknown, sinceMs?: number) => typeof sinceMs === 'number' ? null : staleCaptchaUrl, ), 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((key: string) => { const values: Record = { QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS: '1', QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS: '1', QQBOT_NAPCAT_QUICK_LOGIN_WAIT_MS: '1', }; return values[key] || ''; }), } 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'); mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode( session, container, 'qq-password', ); expect(session.captchaUrl).toBeUndefined(); expect(containerService.detectRuntimeCaptchaUrl).toHaveBeenCalledTimes(2); expect(containerService.detectRuntimeCaptchaUrl).not.toHaveBeenCalledWith( container, ); expect(session.qrcode).toBe('fallback-qrcode'); }); it('does not read tail captcha logs for processing status without restart timestamp', async () => { const staleCaptchaUrl = 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001'; const containerService = { detectRuntimeCaptchaUrl: jest.fn().mockResolvedValue(staleCaptchaUrl), }; const refreshService = new QqbotNapcatLoginService( { get: jest.fn((key: string) => { const values: Record = { QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS: '1', QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS: '1', }; return values[key] || ''; }), } 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, loginError: '密码登录处理中', }); const status = await (refreshService as any).waitForPasswordLoginStatus({ baseUrl: 'http://127.0.0.1:6103/', id: 'container-stale-tail-no-since', name: 'napcat-10001', webuiToken: 'token', }); expect(status.captchaUrl).toBeUndefined(); expect(containerService.detectRuntimeCaptchaUrl).not.toHaveBeenCalled(); }); it('keeps password captcha pending before cleanup when status check throws captcha error', async () => { const captchaUrl = 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001'; const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-password-captcha-error', name: 'napcat-10001', }; const ensureRuntimeLoginEnv = jest.fn().mockResolvedValue({ changed: true, ok: true, }); const containerService = { detectRuntimeCaptchaUrl: jest.fn().mockImplementation(async () => { const cleanedPasswordBeforeLogRead = ensureRuntimeLoginEnv.mock.calls .slice(1) .some(([, options]) => options?.clearLoginPassword === true); return cleanedPasswordBeforeLogRead ? '' : captchaUrl; }), ensureRuntimeLoginEnv, resetRuntimeLoginState: jest.fn().mockResolvedValue(true), restartRuntimeContainer: jest.fn().mockResolvedValue(true), }; const refreshService = new QqbotNapcatLoginService( { get: jest.fn((key: string) => { const values: Record = { QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS: '1', QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS: '1', QQBOT_NAPCAT_QUICK_LOGIN_WAIT_MS: '1', }; return values[key] || ''; }), } 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: '快速登录未找到历史会话', }) .mockRejectedValueOnce( new Error('密码回退需要验证码,请在 WebUi 中继续完成验证'), ); const refreshQrcode = jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode( session, container, 'qq-password', ); expect(containerService.detectRuntimeCaptchaUrl).toHaveBeenCalledWith( container, expect.any(Number), ); expect(refreshQrcode).not.toHaveBeenCalled(); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); expect(ensureRuntimeLoginEnv).not.toHaveBeenCalled(); expect(session.status).toBe('pending'); expect(session.captchaUrl).toBe(captchaUrl); expect(session.passwordMd5).toBeTruthy(); expect(session.errorMessage).toContain('需要完成 QQ 安全验证'); }); it('keeps qrcode pending when password login falls into NapCat qrcode status', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-password-qrcode', name: 'napcat-10001', }; const containerService = { ensureRuntimeLoginEnv: jest .fn() .mockResolvedValueOnce({ changed: false, ok: true }) .mockResolvedValueOnce({ changed: true, ok: true }) .mockResolvedValueOnce({ changed: true, ok: true }), resetRuntimeLoginState: jest.fn().mockResolvedValue(true), restartRuntimeContainer: jest.fn().mockResolvedValue(true), }; const refreshService = new QqbotNapcatLoginService( { get: jest.fn((key: string) => { const values: Record = { QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS: '1', QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS: '10', QQBOT_NAPCAT_QUICK_LOGIN_WAIT_MS: '1', }; return values[key] || ''; }), } 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: '二维码已过期,请刷新', qrcodeurl: 'expired-qrcode', }); const refreshQrcode = jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fresh-qrcode'); mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode( session, container, 'qq-password', ); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); expect(refreshQrcode).toHaveBeenCalledWith( container, true, expect.objectContaining({ requireFresh: true, staleQrcode: 'expired-qrcode', }), ); expect(session.status).toBe('pending'); expect(session.captchaUrl).toBeUndefined(); expect(session.passwordMd5).toBeUndefined(); expect(session.qrcode).toBe('fresh-qrcode'); const steps = ( (refreshService as any).sessionEventLogs.get(session.id) ?? [] ).map((event: { step: string }) => event.step); expect(steps).toContain('password-login-qrcode'); expect(steps).not.toContain('relogin-reset-start'); }); it('prioritizes current qrcode status over stale captcha URL in runtime logs', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-password-qrcode-over-captcha-log', name: 'napcat-10001', }; const containerService = { detectRuntimeCaptchaUrl: jest .fn() .mockResolvedValue( 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001', ), ensureRuntimeLoginEnv: jest .fn() .mockResolvedValueOnce({ changed: false, ok: true }) .mockResolvedValueOnce({ changed: true, ok: true }) .mockResolvedValueOnce({ changed: true, ok: true }), resetRuntimeLoginState: jest.fn().mockResolvedValue(true), restartRuntimeContainer: jest.fn().mockResolvedValue(true), }; const refreshService = new QqbotNapcatLoginService( { get: jest.fn((key: string) => { const values: Record = { QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS: '1', QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS: '10', }; return values[key] || ''; }), } 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: '二维码已过期,请刷新', qrcodeurl: 'expired-qrcode', }); jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fresh-qrcode'); await (refreshService as any).prepareReloginQrcode( session, container, 'qq-password', ); expect(containerService.detectRuntimeCaptchaUrl).not.toHaveBeenCalled(); expect(session.status).toBe('pending'); expect(session.captchaUrl).toBeUndefined(); expect(session.qrcode).toBe('fresh-qrcode'); }); it('submits captcha result back to NapCat and completes password login', async () => { const accountService = { ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), }; const containerService = { bindAccount: jest.fn().mockResolvedValue(undefined), findRuntimeById: jest.fn().mockResolvedValue({ baseUrl: 'http://127.0.0.1:6103/', id: 'container-captcha-submit', name: 'napcat-10001', }), removeUnboundContainer: jest.fn().mockResolvedValue(false), }; 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: { id: 'container-captcha-submit', name: 'napcat-10001', }, expectedSelfId: '10001', mode: 'refresh', status: 'pending', }); session.captchaUrl = 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001&sid=sid-from-url'; session.passwordMd5 = '0123456789abcdef0123456789abcdef'; (refreshService as any).sessions.set(session.id, session); const postNapcat = jest .spyOn(refreshService as any, 'postNapcat') .mockResolvedValueOnce(null); jest .spyOn(refreshService as any, 'waitForPasswordLoginStatus') .mockResolvedValue({ isLogin: true, }); jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ nickname: 'Kwi', online: true, uin: '10001', }); const result = await refreshService.submitCaptcha(session.id, { randstr: '@rand', sid: 'sid-from-url', ticket: 'captcha-ticket', }); expect(postNapcat).toHaveBeenCalledWith( expect.objectContaining({ id: 'container-captcha-submit' }), '/api/QQLogin/CaptchaLogin', { passwordMd5: '0123456789abcdef0123456789abcdef', randstr: '@rand', sid: 'sid-from-url', ticket: 'captcha-ticket', uin: '10001', }, ); expect(result.status).toBe('success'); expect(session.captchaUrl).toBeUndefined(); expect(session.passwordMd5).toBeUndefined(); }); it('generates new-device verification QR through NapCat after captcha requires new device', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-new-device', name: 'napcat-10001', }; const containerService = { findRuntimeById: jest.fn().mockResolvedValue(container), removeUnboundContainer: jest.fn().mockResolvedValue(false), }; 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', status: 'pending', }); session.captchaUrl = 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001&sid=sid-from-url'; session.passwordMd5 = '0123456789abcdef0123456789abcdef'; (refreshService as any).sessions.set(session.id, session); const postNapcat = jest .spyOn(refreshService as any, 'postNapcat') .mockImplementation(async (_runtime, path: string) => { if (path === '/api/QQLogin/CaptchaLogin') { return { jumpUrl: 'https://ti.qq.com/new-device/verify', needNewDevice: true, newDevicePullQrCodeSig: 'sig-new-device', }; } if (path === '/api/QQLogin/GetNewDeviceQRCode') { return { bytes_token: 'bytes-new-device', newDevicePullQrCodeSig: 'sig-new-device', qrcodeUrl: 'data:image/png;base64,new-device-qrcode', }; } throw new Error(`unexpected NapCat path ${path}`); }); const waitForPasswordLoginStatus = jest.spyOn( refreshService as any, 'waitForPasswordLoginStatus', ); const result = await refreshService.submitCaptcha(session.id, { randstr: '@rand', sid: 'sid-from-url', ticket: 'captcha-ticket', }); expect(postNapcat).toHaveBeenNthCalledWith( 2, container, '/api/QQLogin/GetNewDeviceQRCode', { jumpUrl: 'https://ti.qq.com/new-device/verify', uin: '10001', }, ); expect(waitForPasswordLoginStatus).not.toHaveBeenCalled(); expect(result.status).toBe('pending'); expect(result.captchaUrl).toBeUndefined(); expect(result.qrcode).toBeUndefined(); expect(result.newDeviceQrcode).toBe( 'data:image/png;base64,new-device-qrcode', ); expect(result.newDeviceStatus).toBe('qr-pending'); expect(result.errorMessage).toBe('新设备二维码待扫码'); expect(session.newDevicePullQrCodeSig).toBe('sig-new-device'); expect(session.newDeviceBytesToken).toBe('bytes-new-device'); expect(session.deviceVerifyUrl).toBe('https://ti.qq.com/new-device/verify'); const steps = ( (refreshService as any).sessionEventLogs.get(session.id) ?? [] ).map((event: { step: string }) => event.step); expect(steps).toEqual( expect.arrayContaining([ 'password-login-captcha-submit', 'new-device-required', 'new-device-qrcode-ready', ]), ); }); it('polls new-device QR and completes NewDeviceLogin before password success', async () => { const accountService = { ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), }; const pullQrCodeSig = { key: 'sig-new-device' }; const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-new-device-poll', name: 'napcat-10001', }; const containerService = { bindAccount: jest.fn().mockResolvedValue(undefined), findRuntimeById: jest.fn().mockResolvedValue(container), removeUnboundContainer: jest.fn().mockResolvedValue(false), }; 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', status: 'pending', }); session.deviceVerifyUrl = 'https://ti.qq.com/new-device/verify'; session.newDeviceBytesToken = 'bytes-new-device'; session.newDevicePullQrCodeSig = pullQrCodeSig; session.newDeviceQrcode = 'data:image/png;base64,new-device-qrcode'; session.newDeviceStatus = 'qr-pending'; session.passwordMd5 = '0123456789abcdef0123456789abcdef'; (refreshService as any).sessions.set(session.id, session); const postNapcat = jest .spyOn(refreshService as any, 'postNapcat') .mockResolvedValueOnce({ status: 'scanned', }) .mockResolvedValueOnce({ status: 'confirming', }) .mockResolvedValueOnce({ success: true, }); jest .spyOn(refreshService as any, 'waitForPasswordLoginStatus') .mockResolvedValue({ isLogin: true, }); jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ nickname: 'Kwi', online: true, uin: '10001', }); const scanned = (await refreshService.status(session.id)) as any; const completed = await refreshService.status(session.id); expect(scanned.status).toBe('pending'); expect(scanned.newDeviceStatus).toBe('scanned'); expect(scanned.errorMessage).toBe('新设备二维码已扫码'); expect(completed.status).toBe('success'); expect(postNapcat).toHaveBeenNthCalledWith( 1, container, '/api/QQLogin/PollNewDeviceQR', { bytesToken: 'bytes-new-device', uin: '10001', }, ); expect(postNapcat).toHaveBeenNthCalledWith( 2, container, '/api/QQLogin/PollNewDeviceQR', { bytesToken: 'bytes-new-device', uin: '10001', }, ); expect(postNapcat).toHaveBeenNthCalledWith( 3, container, '/api/QQLogin/NewDeviceLogin', { newDevicePullQrCodeSig: pullQrCodeSig, passwordMd5: '0123456789abcdef0123456789abcdef', uin: '10001', }, ); expect(session.newDeviceQrcode).toBeUndefined(); expect(session.newDeviceStatus).toBe('verified'); const steps = ( (refreshService as any).sessionEventLogs.get(session.id) ?? [] ).map((event: { step: string }) => event.step); expect(steps).toEqual( expect.arrayContaining([ 'new-device-scanned', 'new-device-confirming', 'new-device-verified', 'login-success', ]), ); }); it('uses NapCat confirmed token and reloads saved password when new-device session lost passwordMd5', async () => { const account = { id: 'account-1', napcatLoginPasswordSecret: 'encrypted-secret', selfId: '10001', }; const accountService = { ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), findByIdWithNapcatLoginSecret: jest.fn().mockResolvedValue(account), getNapcatLoginPassword: jest.fn().mockReturnValue('qq-password'), }; const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-new-device-confirmed', name: 'napcat-10001', }; const containerService = { bindAccount: jest.fn().mockResolvedValue(undefined), findRuntimeById: jest.fn().mockResolvedValue(container), removeUnboundContainer: jest.fn().mockResolvedValue(false), }; 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', status: 'pending', }); session.deviceVerifyUrl = 'https://accounts.qq.com/safe/verify?sig=sig&uin-token=token'; session.newDeviceBytesToken = 'bytes-new-device'; session.newDevicePullQrCodeSig = 'initial-sig'; session.newDeviceQrcode = 'data:image/png;base64,new-device-qrcode'; session.newDeviceStatus = 'qr-pending'; (refreshService as any).sessions.set(session.id, session); const postNapcat = jest .spyOn(refreshService as any, 'postNapcat') .mockResolvedValueOnce({ str_nt_succ_token: 'nt-success-token', uint32_guarantee_status: 1, }) .mockResolvedValueOnce({ success: true, }); jest .spyOn(refreshService as any, 'waitForPasswordLoginStatus') .mockResolvedValue({ isLogin: true, }); jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ nickname: 'Kwi', online: true, uin: '10001', }); const result = await refreshService.status(session.id); expect(result.status).toBe('success'); expect(accountService.findByIdWithNapcatLoginSecret).toHaveBeenCalledWith( 'account-1', ); expect(accountService.getNapcatLoginPassword).toHaveBeenCalledWith(account); expect(postNapcat).toHaveBeenNthCalledWith( 2, container, '/api/QQLogin/NewDeviceLogin', { newDevicePullQrCodeSig: 'nt-success-token', passwordMd5: '7fe1f9ae1130b64e9ca1441492c382c0', uin: '10001', }, ); expect(session.newDeviceStatus).toBe('verified'); }); it('recovers missing new-device bytesToken before polling', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-new-device-recover', name: 'napcat-10001', }; const containerService = { findRuntimeById: jest.fn().mockResolvedValue(container), removeUnboundContainer: jest.fn().mockResolvedValue(false), }; 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', status: 'pending', }); session.deviceVerifyUrl = 'https://ti.qq.com/new-device/verify'; session.newDevicePullQrCodeSig = 'sig-new-device'; session.newDeviceStatus = 'qr-pending'; session.passwordMd5 = '0123456789abcdef0123456789abcdef'; (refreshService as any).sessions.set(session.id, session); const postNapcat = jest .spyOn(refreshService as any, 'postNapcat') .mockResolvedValue({ bytes_token: 'bytes-recovered', qrcodeUrl: 'data:image/png;base64,recovered-new-device-qrcode', }); const result = await refreshService.status(session.id); expect(result.status).toBe('pending'); expect(result.newDeviceStatus).toBe('qr-pending'); expect(result.newDeviceQrcode).toBe( 'data:image/png;base64,recovered-new-device-qrcode', ); expect(session.newDeviceBytesToken).toBe('bytes-recovered'); expect(postNapcat).toHaveBeenCalledTimes(1); expect(postNapcat).toHaveBeenCalledWith( container, '/api/QQLogin/GetNewDeviceQRCode', { jumpUrl: 'https://ti.qq.com/new-device/verify', uin: '10001', }, ); }); it('clears password challenge session state when cancelling captcha pending login', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-captcha-cancel', name: 'napcat-10001', }; const containerService = { ensureRuntimeLoginEnv: jest.fn().mockResolvedValue({ changed: true, ok: true, }), findRuntimeById: jest.fn().mockResolvedValue(container), removeUnboundContainer: jest.fn().mockResolvedValue(false), }; 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', status: 'pending', }); session.captchaUrl = 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001'; session.passwordMd5 = '0123456789abcdef0123456789abcdef'; (refreshService as any).sessions.set(session.id, session); await refreshService.cancel(session.id); expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); expect(session.passwordMd5).toBeUndefined(); expect(session.captchaUrl).toBeUndefined(); }); it('does not block captcha success with runtime password env cleanup', async () => { const accountService = { ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), }; const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-captcha-cleanup-failed', name: 'napcat-10001', }; const containerService = { bindAccount: jest.fn().mockResolvedValue(undefined), ensureRuntimeLoginEnv: jest.fn().mockResolvedValue({ changed: false, ok: false, }), findRuntimeById: jest.fn().mockResolvedValue(container), removeUnboundContainer: jest.fn().mockResolvedValue(false), }; 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', status: 'pending', }); session.captchaUrl = 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001&sid=sid-from-url'; session.passwordMd5 = '0123456789abcdef0123456789abcdef'; (refreshService as any).sessions.set(session.id, session); jest.spyOn(refreshService as any, 'postNapcat').mockResolvedValueOnce(null); jest .spyOn(refreshService as any, 'waitForPasswordLoginStatus') .mockResolvedValue({ isLogin: true, }); jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ nickname: 'Kwi', online: true, uin: '10001', }); const result = await refreshService.submitCaptcha(session.id, { randstr: '@rand', sid: 'sid-from-url', ticket: 'captcha-ticket', }); expect(result.status).toBe('success'); expect(accountService.ensureScannedAccount).toHaveBeenCalledWith({ accountId: 'account-1', name: 'Kwi', selfId: '10001', }); const steps = ( (refreshService as any).sessionEventLogs.get(session.id) ?? [] ).map((event: { step: string }) => event.step); expect(steps).not.toContain('password-env-cleanup-failed'); expect(steps).toContain('login-success'); }); it('returns captcha status immediately without waiting full password window', async () => { const captchaUrl = 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001'; const refreshService = new QqbotNapcatLoginService( { get: jest.fn((key: string) => { const values: Record = { QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS: '1000', QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS: '3000', }; return values[key] || ''; }), } as unknown as ConfigService, {} as QqbotAccountService, {} as QqbotNapcatContainerService, new ToolsService(), ); jest.spyOn(refreshService as any, 'postNapcat').mockResolvedValueOnce({ isLogin: false, loginError: `需要验证码, proofWaterUrl: ${captchaUrl}`, }); const sleep = jest.spyOn((refreshService as any).toolsService, 'sleep'); const status = await (refreshService as any).waitForPasswordLoginStatus({ baseUrl: 'http://127.0.0.1:6103/', id: 'container-captcha-early', name: 'napcat-10001', webuiToken: 'token', }); expect(status.loginError).toContain(captchaUrl); expect(sleep).not.toHaveBeenCalled(); }); it('captures captcha url from runtime logs when status check throws captcha message without url', async () => { const restartedAt = Date.now() - 1500; const captchaUrl = 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001'; const containerService = { detectRuntimeCaptchaUrl: jest.fn().mockResolvedValue(captchaUrl), }; const refreshService = new QqbotNapcatLoginService( { get: jest.fn((key: string) => { const values: Record = { QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS: '1000', QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS: '3000', }; return values[key] || ''; }), } as unknown as ConfigService, {} as QqbotAccountService, containerService as unknown as QqbotNapcatContainerService, new ToolsService(), ); jest .spyOn(refreshService as any, 'getLoginStatus') .mockRejectedValueOnce(new Error('需要验证码')); const sleep = jest.spyOn((refreshService as any).toolsService, 'sleep'); const status = await (refreshService as any).waitForPasswordLoginStatus( { baseUrl: 'http://127.0.0.1:6103/', id: 'container-captcha-log', name: 'napcat-10001', webuiToken: 'token', }, restartedAt, ); expect(status).toMatchObject({ captchaUrl, isLogin: false, loginError: '需要验证码', }); expect(containerService.detectRuntimeCaptchaUrl).toHaveBeenCalledWith( expect.objectContaining({ name: 'napcat-10001' }), restartedAt, ); expect(sleep).not.toHaveBeenCalled(); }); it('returns captcha status during password wait when processing logs already contain captcha url', async () => { const restartedAt = Date.now() - 1500; const captchaUrl = 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001'; const containerService = { detectRuntimeCaptchaUrl: jest.fn().mockResolvedValue(captchaUrl), }; const refreshService = new QqbotNapcatLoginService( { get: jest.fn((key: string) => { const values: Record = { QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS: '1000', QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS: '3000', }; return values[key] || ''; }), } as unknown as ConfigService, {} as QqbotAccountService, containerService as unknown as QqbotNapcatContainerService, new ToolsService(), ); const getLoginStatus = jest .spyOn(refreshService as any, 'getLoginStatus') .mockResolvedValue({ isLogin: false, loginError: '密码登录处理中', }); const sleep = jest .spyOn((refreshService as any).toolsService, 'sleep') .mockResolvedValue(undefined); const status = await (refreshService as any).waitForPasswordLoginStatus( { baseUrl: 'http://127.0.0.1:6103/', id: 'container-captcha-log-processing', name: 'napcat-10001', webuiToken: 'token', }, restartedAt, ); expect(status).toMatchObject({ captchaUrl, isLogin: false, loginError: '密码登录处理中', }); expect(containerService.detectRuntimeCaptchaUrl).toHaveBeenCalledWith( expect.objectContaining({ name: 'napcat-10001' }), restartedAt, ); expect(getLoginStatus).toHaveBeenCalledTimes(1); expect(sleep).not.toHaveBeenCalled(); }); it('returns qrcode status immediately without waiting full password window', async () => { const refreshService = new QqbotNapcatLoginService( { get: jest.fn((key: string) => { const values: Record = { QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS: '1000', QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS: '3000', }; return values[key] || ''; }), } as unknown as ConfigService, {} as QqbotAccountService, {} as QqbotNapcatContainerService, new ToolsService(), ); const getLoginStatus = jest .spyOn(refreshService as any, 'getLoginStatus') .mockResolvedValue({ isLogin: false, loginError: '二维码已过期,请刷新', qrcodeurl: 'expired-qrcode', }); const sleep = jest.spyOn((refreshService as any).toolsService, 'sleep'); const status = await (refreshService as any).waitForPasswordLoginStatus({ baseUrl: 'http://127.0.0.1:6103/', id: 'container-qrcode-early', name: 'napcat-10001', webuiToken: 'token', }); expect(status.qrcodeurl).toBe('expired-qrcode'); expect(getLoginStatus).toHaveBeenCalledTimes(1); expect(sleep).not.toHaveBeenCalled(); }); it('does not keep stale captcha pending after captcha submit returns non-captcha failure', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-captcha-normal-failure', name: 'napcat-10001', }; const containerService = { ensureRuntimeLoginEnv: jest.fn().mockResolvedValue({ changed: true, ok: true, }), findRuntimeById: jest.fn().mockResolvedValue(container), removeUnboundContainer: jest.fn().mockResolvedValue(false), }; 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', status: 'pending', }); session.captchaUrl = 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001'; session.passwordMd5 = '0123456789abcdef0123456789abcdef'; (refreshService as any).sessions.set(session.id, session); jest.spyOn(refreshService as any, 'postNapcat').mockResolvedValueOnce(null); jest .spyOn(refreshService as any, 'waitForPasswordLoginStatus') .mockResolvedValue({ isLogin: false, loginError: '密码错误', }); const result = await refreshService.submitCaptcha(session.id, { randstr: '@rand', ticket: 'captcha-ticket', }); expect(result.status).toBe('error'); expect(result.captchaUrl).toBeUndefined(); expect(result.errorMessage).toBe('验证码登录未完成:密码错误'); expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); }); 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'); mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode( session, container, 'qq-password', false, ); expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); 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('completes password refresh without runtime password env cleanup', 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', }); mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode( session, container, 'qq-password', ); expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); expect(accountService.ensureScannedAccount).toHaveBeenCalledWith({ accountId: 'account-1', name: 'Kwi', selfId: '10001', }); expect(containerService.bindAccount).toHaveBeenCalledWith( 'account-1', 'container-password-cleanup-failed', '10001', ); expect(session.status).toBe('success'); const steps = ( (refreshService as any).sessionEventLogs.get(session.id) ?? [] ).map((event: { step: string }) => event.step); expect(steps).not.toContain('password-env-cleanup'); expect(steps).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: 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') .mockResolvedValueOnce({ isLogin: false, loginError: '快速登录未找到历史会话', }) .mockResolvedValue({ isLogin: false, loginError: '密码登录未完成', }); jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode( session, container, 'qq-password', ); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); 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('qrcode-fetch'), ); }); it('enters qrcode fallback when password login fails without runtime env cleanup', 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: '快速登录未找到历史会话', }) .mockResolvedValue({ isLogin: false, loginError: '密码登录未完成', }); const refreshQrcode = jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode( session, container, 'qq-password', ); expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); expect(refreshQrcode).toHaveBeenCalledWith( container, false, expect.objectContaining({ requireFresh: true }), ); expect(session.status).toBe('pending'); expect(session.qrcode).toBe('fallback-qrcode'); }); 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'); mockWebuiLoginPost(refreshService); 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('keeps successful refresh login terminal when status is polled after background relogin', async () => { (service as any).sessions.set('session-success', { accountId: 'account-1', containerId: 'container-success', containerName: 'napcat-success', createdAt: Date.now(), expiresAt: Date.now() + 60_000, id: 'session-success', mode: 'refresh', preparingRelogin: true, status: 'success', webuiPort: 6108, }); const getLoginStatus = jest.spyOn(service as any, 'getLoginStatus'); const result = await service.status('session-success'); expect(result.status).toBe('success'); expect(result.errorMessage).toBeUndefined(); expect(getLoginStatus).not.toHaveBeenCalled(); }); it('keeps failed refresh login terminal when status is polled after background relogin', async () => { (service as any).sessions.set('session-error', { accountId: 'account-1', containerId: 'container-error', containerName: 'napcat-error', createdAt: Date.now(), errorMessage: 'NapCat 快速登录失败', expiresAt: Date.now() + 60_000, id: 'session-error', mode: 'refresh', preparingRelogin: true, status: 'error', webuiPort: 6109, }); const getLoginStatus = jest.spyOn(service as any, 'getLoginStatus'); const result = await service.status('session-error'); expect(result.status).toBe('error'); expect(result.errorMessage).toBe('NapCat 快速登录失败'); expect(getLoginStatus).not.toHaveBeenCalled(); }); it('persists expired scan sessions before removing them from the runtime cache', async () => { const loginSessionRepository = createLoginSessionRepository(); const loginStateStore = new NapcatLoginStateStoreService( loginSessionRepository as any, ); const expireService = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, {} as QqbotAccountService, { removeUnboundContainer: jest.fn().mockResolvedValue(undefined), } as unknown as QqbotNapcatContainerService, new ToolsService(), loginStateStore, ); const session = { containerId: 'container-expired-persist', containerName: 'napcat-expired-persist', createdAt: Date.now() - 120_000, expiresAt: Date.now() - 1, id: 'session-expired-persist', mode: 'refresh', qrcode: 'expired-qrcode', status: 'pending', webuiPort: 6110, } as const; (expireService as any).sessions.set(session.id, session); await loginStateStore.flushSessionWrites(session.id); const result = await expireService.status(session.id); await loginStateStore.flushSessionWrites(session.id); expect(result.status).toBe('expired'); expect(loginSessionRepository.rows).toHaveLength(1); expect(loginSessionRepository.rows[0]).toEqual( expect.objectContaining({ sessionKey: session.id, status: 'expired', }), ); expect(loginSessionRepository.rows[0].completedAt).toEqual( expect.any(Date), ); expect(loginSessionRepository.rows[0].sessionPayload?.status).toBe( 'expired', ); }); it('does not complete an expired refresh session from a delayed background relogin task', async () => { const loginSessionRepository = createLoginSessionRepository(); const loginStateStore = new NapcatLoginStateStoreService( loginSessionRepository as any, ); const accountService = { ensureScannedAccount: jest.fn().mockResolvedValue('account-expired'), }; const containerService = { bindAccount: jest.fn().mockResolvedValue(undefined), findRuntimeById: jest.fn().mockResolvedValue({ id: 'container-expired' }), removeUnboundContainer: jest.fn().mockResolvedValue(undefined), }; const expireService = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, accountService as unknown as QqbotAccountService, containerService as unknown as QqbotNapcatContainerService, new ToolsService(), loginStateStore, ); const session = { accountId: 'account-expired', containerId: 'container-expired', containerName: 'napcat-expired', createdAt: Date.now() - 180_000, expectedSelfId: '10001', expiresAt: Date.now() - 1, id: 'session-expired-background', mode: 'refresh', qrcode: 'expired-qrcode', status: 'pending', webuiPort: 6110, }; (expireService as any).sessions.set(session.id, session); await loginStateStore.flushSessionWrites(session.id); jest.spyOn(expireService as any, 'getLoginInfo').mockResolvedValue({ nick: 'Mirror', online: true, uin: '10001', }); const result = await (expireService as any).completeLogin(session, { id: 'container-expired', }); await loginStateStore.flushSessionWrites(session.id); expect(result.status).toBe('expired'); expect(accountService.ensureScannedAccount).not.toHaveBeenCalled(); expect(containerService.bindAccount).not.toHaveBeenCalled(); expect(loginSessionRepository.rows[0]).toEqual( expect.objectContaining({ sessionKey: session.id, status: 'expired', }), ); }); it('refreshes stale relogin qrcode from NapCat status instead of staying in quick login pending', async () => { (service as any).sessions.set('session-stale-relogin-qrcode', { accountId: 'account-1', containerId: 'container-stale-relogin', containerName: 'napcat-stale-relogin', createdAt: Date.now(), errorMessage: 'NapCat 正在尝试快速登录,请稍后', expectedSelfId: '10001', expiresAt: Date.now() + 60_000, id: 'session-stale-relogin-qrcode', lastRestartedAt: 1, mode: 'refresh', preparingRelogin: true, status: 'pending', webuiPort: 6110, }); const container = { id: 'container-stale-relogin' }; jest .spyOn(service as any, 'getSessionContainer') .mockResolvedValue(container); jest.spyOn(service as any, 'getLoginStatus').mockResolvedValue({ isLogin: false, qrcodeurl: 'status-qrcode', }); jest .spyOn(service as any, 'callRefreshQrcode') .mockResolvedValue('fresh-qrcode'); const result = await service.refreshQrcode('session-stale-relogin-qrcode'); expect(result.status).toBe('pending'); expect(result.qrcode).toBe('fresh-qrcode'); expect(result.errorMessage).toBeUndefined(); expect((service as any).sessions.get('session-stale-relogin-qrcode')).toEqual( expect.objectContaining({ preparingRelogin: false, qrcode: 'fresh-qrcode', }), ); }); it('syncs account qrcode status when scan status reads a NapCat qrcode', async () => { const accountService = { markQqLoginStatus: jest.fn(), }; const syncService = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, accountService as unknown as QqbotAccountService, {} as QqbotNapcatContainerService, new ToolsService(), ); (syncService as any).sessions.set('session-sync-qrcode-status', { accountId: 'account-1', containerId: 'container-sync-status', containerName: 'napcat-sync-status', createdAt: Date.now(), expectedSelfId: '10001', expiresAt: Date.now() + 60_000, id: 'session-sync-qrcode-status', mode: 'refresh', status: 'pending', webuiPort: 6111, }); jest .spyOn(syncService as any, 'getSessionContainer') .mockResolvedValue({ id: 'container-sync-status' }); jest.spyOn(syncService as any, 'getLoginStatus').mockResolvedValue({ isLogin: false, qrcodeurl: 'status-qrcode', }); jest .spyOn(syncService as any, 'getQrcode') .mockResolvedValue('status-qrcode'); const result = await syncService.status('session-sync-qrcode-status'); expect(result.qrcode).toBe('status-qrcode'); expect(accountService.markQqLoginStatus).toHaveBeenCalledWith( '10001', 'qrcode_pending', null, ); }); 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('emits a current session snapshot when SSE event cache was lost', async () => { const session = { accountId: 'account-1', containerId: 'container-events-snapshot', containerName: 'napcat-events-snapshot', createdAt: Date.now(), errorMessage: 'NapCat 正在尝试密码登录,请稍后', expiresAt: Date.now() + 60_000, id: 'session-events-snapshot', mode: 'refresh', preparingRelogin: true, status: 'pending', webuiPort: 6107, }; (service as any).sessions.set('session-events-snapshot', session); const events: any[] = []; const subscription = service .events('session-events-snapshot') .subscribe((event) => events.push(event.data)); await new Promise((resolve) => setImmediate(resolve)); expect(events).toEqual([ expect.objectContaining({ message: 'NapCat 正在尝试密码登录,请稍后', result: expect.objectContaining({ sessionId: 'session-events-snapshot', status: 'pending', }), status: 'processing', step: 'password-login-start', }), ]); 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('accepts qrcode from login status for refresh session without stored qrcode', async () => { (service as any).sessions.set('session-status-qrcode', { accountId: 'account-1', containerId: 'container-status-qrcode', containerName: 'napcat-status-qrcode', createdAt: Date.now(), errorMessage: 'NapCat 正在重新生成二维码,请稍后', expiresAt: Date.now() + 60_000, id: 'session-status-qrcode', mode: 'refresh', status: 'pending', webuiPort: 6106, }); jest .spyOn(service as any, 'getSessionContainer') .mockResolvedValue({ id: 'container-status-qrcode' }); jest.spyOn(service as any, 'getLoginStatus').mockResolvedValue({ isLogin: false, qrcodeurl: 'fresh-status-qrcode', }); const getQrcode = jest .spyOn(service as any, 'getQrcode') .mockRejectedValue(new Error('NapCat 二维码仍未刷新')); const result = await service.status('session-status-qrcode'); expect(result.status).toBe('pending'); expect(result.qrcode).toBe('fresh-status-qrcode'); expect(result.errorMessage).toBeUndefined(); expect(getQrcode).not.toHaveBeenCalled(); }); 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('returns a refresh qrcode without restarting 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); jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('offline-refresh-qrcode'); 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).toBe('offline-refresh-qrcode'); expect(result.errorMessage).toBeUndefined(); expect(restartNapcatForLogin).not.toHaveBeenCalled(); }); it('refreshes qrcode through WebUI without restarting 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).not.toHaveBeenCalled(); 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('rejects stale status qrcode when NapCat still blocks qrcode generation as already logged in', async () => { jest .spyOn(service as any, 'postNapcat') .mockRejectedValue(new Error('QQ Is Logined')); jest.spyOn(service as any, 'getLoginStatus').mockResolvedValue({ isLogin: false, isOffline: true, qrcodeurl: 'old-status-qrcode', }); await expect( (service as any).getQrcode({ id: 'container-stale-login-guard' }, false, { requireFresh: true, }), ).rejects.toThrow('NapCat WebUI 登录态仍阻止生成新二维码'); }); it('keeps the current refresh qrcode during ordinary status polling', async () => { (service as any).sessions.set('session-status-stale-qrcode', { accountId: 'account-1', containerId: 'container-status-stale', containerName: 'napcat-status-stale', createdAt: Date.now(), expiresAt: Date.now() + 60_000, id: 'session-status-stale-qrcode', mode: 'refresh', qrcode: 'old-qrcode', status: 'pending', webuiPort: 6101, }); const container = { id: 'container-status-stale' }; jest .spyOn(service as any, 'getSessionContainer') .mockResolvedValue(container); jest.spyOn(service as any, 'getLoginStatus').mockResolvedValue({ isLogin: false, qrcodeurl: 'old-qrcode', }); const getQrcode = jest .spyOn(service as any, 'getQrcode') .mockRejectedValue(new Error('NapCat 二维码仍未刷新')); const result = await service.status('session-status-stale-qrcode'); expect(result.status).toBe('pending'); expect(result.qrcode).toBe('old-qrcode'); expect(result.errorMessage).toBeUndefined(); expect(getQrcode).not.toHaveBeenCalled(); }); 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('二维码已过期'); }); });