jest.mock('@/common', () => ({ ToolsService: jest.requireActual('@/common').ToolsService, ensureSnowflakeId: jest.fn(), setDictDecodeCache: jest.fn(), throwVbenError: (message: string) => { throw new Error(message); }, })); import { ConfigService } from '@nestjs/config'; import { ToolsService } from '@/common'; import { QqbotAccountService } from '@/qqbot/account/qqbot-account.service'; import { QqbotNapcatLoginService } from '@/qqbot/account/qqbot-napcat-login.service'; import { QqbotNapcatContainerService } from '@/qqbot/napcat/qqbot-napcat-container.service'; describe('QqbotNapcatLoginService', () => { const toolsService = new ToolsService(); const service = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, {} as QqbotAccountService, {} as QqbotNapcatContainerService, toolsService, ); beforeEach(() => { (service as any).sessions.clear(); 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), }; 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(containerService.prepareAccountContainer).toHaveBeenCalledWith( account, ); expect(startScan).toHaveBeenCalledWith( { accountId: 'account-1', expectedSelfId: '10001', forceRelogin: true, mode: 'refresh', }, existingContainer, ); }); it('resets the existing container login state before refresh login scan', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-current', name: 'napcat-10001', }; const containerService = { resetRuntimeLoginState: 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, 'cleanupSessions') .mockResolvedValue(undefined); jest .spyOn(refreshService as any, 'getLoginStatus') .mockResolvedValueOnce({ isLogin: false }) .mockResolvedValueOnce({ isLogin: false }); jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fresh-qrcode'); 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).toBe('fresh-qrcode'); expect(containerService.resetRuntimeLoginState).toHaveBeenCalledWith( container, ); }); it('requires a qrcode different from the current one when refreshing qrcode', async () => { (service as any).sessions.set('session-refresh-qrcode', { containerId: 'container-3', containerName: 'napcat-3', createdAt: Date.now(), expiresAt: Date.now() + 60_000, id: 'session-refresh-qrcode', mode: 'refresh', qrcode: 'old-qrcode', status: 'pending', webuiPort: 6103, }); const container = { id: 'container-3' }; jest .spyOn(service as any, 'getSessionContainer') .mockResolvedValue(container); jest.spyOn(service as any, 'getLoginStatus').mockResolvedValue({ isLogin: false, qrcodeurl: 'old-qrcode', }); jest.spyOn(service as any, 'callRefreshQrcode').mockResolvedValue(''); jest.spyOn(service as any, 'getQrcode').mockResolvedValue('new-qrcode'); const result = await service.refreshQrcode('session-refresh-qrcode'); expect(result.qrcode).toBe('new-qrcode'); expect((service as any).getQrcode).toHaveBeenCalledWith(container, false, { requireFresh: true, staleQrcode: 'old-qrcode', }); }); it('keeps the refresh session pending when NapCat is still regenerating qrcode', async () => { (service as any).sessions.set('session-pending-qrcode', { containerId: 'container-pending', containerName: 'napcat-pending', createdAt: Date.now(), expiresAt: Date.now() + 60_000, id: 'session-pending-qrcode', mode: 'refresh', qrcode: 'old-qrcode', status: 'pending', webuiPort: 6105, }); jest .spyOn(service as any, 'getSessionContainer') .mockResolvedValue({ id: 'container-pending' }); jest.spyOn(service as any, 'getLoginStatus').mockResolvedValue({ isLogin: false, qrcodeurl: 'old-qrcode', }); jest .spyOn(service as any, 'callRefreshQrcode') .mockRejectedValue(new Error('NapCat 二维码仍未刷新')); const result = await service.refreshQrcode('session-pending-qrcode'); expect(result.status).toBe('pending'); expect(result.qrcode).toBeUndefined(); expect(result.errorMessage).toContain('正在重新生成二维码'); }); it('normalizes login status to offline when login info reports offline', async () => { jest .spyOn(service as any, 'postNapcat') .mockResolvedValueOnce({ isLogin: true, qrcodeurl: 'old-qrcode' }) .mockResolvedValueOnce({ online: false, uin: '10001' }); const result = await (service as any).getLoginStatus({ id: 'container-offline', }); expect(result).toEqual( expect.objectContaining({ isLogin: false, isOffline: true, }), ); }); it('restarts container and returns pending when refresh login status is effectively offline', async () => { const refreshService = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, {} as QqbotAccountService, {} as QqbotNapcatContainerService, new ToolsService(), ); jest .spyOn(refreshService as any, 'cleanupSessions') .mockResolvedValue(null); jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ isLogin: false, isOffline: true, loginError: 'NapCat 账号已离线', qrcodeurl: 'old-qrcode', }); const restartNapcatForLogin = jest .spyOn(refreshService as any, 'restartNapcatForLogin') .mockResolvedValue(undefined); const result = await (refreshService as any).startScan( { accountId: 'account-1', expectedSelfId: '10001', mode: 'refresh', }, { baseUrl: 'http://127.0.0.1:6105/', id: 'container-offline', name: 'napcat-10001', }, ); expect(result.status).toBe('pending'); expect(result.qrcode).toBeUndefined(); expect(result.errorMessage).toBe('NapCat 账号已离线'); expect(restartNapcatForLogin).toHaveBeenCalledWith( expect.objectContaining({ id: 'container-offline' }), { waitForReady: false }, ); }); it('restarts the existing NapCat container before qrcode refresh when account is kicked offline', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-3', name: 'napcat-10001', }; const containerService = { restartRuntimeContainer: jest.fn().mockResolvedValue(true), }; const refreshService = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, {} as QqbotAccountService, containerService as unknown as QqbotNapcatContainerService, new ToolsService(), ); jest .spyOn((refreshService as any).toolsService, 'sleep') .mockResolvedValue(undefined); jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ isLogin: false, qrcodeurl: 'new-status-qrcode', }); jest .spyOn(refreshService as any, 'callRefreshQrcode') .mockResolvedValue(''); jest .spyOn(refreshService as any, 'getQrcode') .mockResolvedValue('new-qrcode'); const result = await (refreshService as any).refreshOrGetQrcode( container, true, { fallbackStatus: { isLogin: false, isOffline: true, loginError: '您的账号已在另一台终端登录', qrcodeurl: 'old-qrcode', }, }, ); expect(result).toBe('new-qrcode'); expect(containerService.restartRuntimeContainer).toHaveBeenCalledWith( container, ); expect((refreshService as any).getQrcode).toHaveBeenCalledWith( container, true, { requireFresh: true, staleQrcode: 'old-qrcode', }, ); }); it('retries while NapCat still exposes the stale qrcode', async () => { jest .spyOn((service as any).toolsService, 'sleep') .mockResolvedValue(undefined); jest .spyOn(service as any, 'postNapcat') .mockResolvedValueOnce({ qrcode: 'old-qrcode' }) .mockResolvedValueOnce({ qrcode: 'new-qrcode' }); const result = await (service as any).getQrcode( { id: 'container-4' }, true, { requireFresh: true, staleQrcode: 'old-qrcode', }, ); expect(result).toBe('new-qrcode'); expect((service as any).postNapcat).toHaveBeenCalledTimes(2); }); it('does not replace current qrcode with expired status qrcode', async () => { (service as any).sessions.set('session-2', { containerId: 'container-2', containerName: 'napcat-2', createdAt: Date.now(), expiresAt: Date.now() + 60_000, id: 'session-2', mode: 'refresh', qrcode: 'current-qrcode', status: 'pending', webuiPort: 6101, }); jest .spyOn(service as any, 'getSessionContainer') .mockResolvedValue({ id: 'container-2' }); jest.spyOn(service as any, 'getLoginStatus').mockResolvedValue({ isLogin: false, loginError: '二维码已过期', qrcodeurl: 'expired-qrcode', }); const result = await service.status('session-2'); expect(result.qrcode).toBe('current-qrcode'); expect(result.errorMessage).toBe('二维码已过期'); }); });