// StreamBasic must be evaluated before OneBotAction because of their circular import. import '../napcat-onebot/action/stream/StreamBasic'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import * as fileUtils from '@/napcat-common/src/file'; import { SendQzoneMsg } from '@/napcat-onebot/action/extends/SendQzoneMsg'; import { DeleteQzoneMsg } from '@/napcat-onebot/action/extends/DeleteQzoneMsg'; const tempDirs: string[] = []; function createCore () { const directory = mkdtempSync(join(tmpdir(), 'napcat-qzone-compat-')); tempDirs.push(directory); return { NapCatTempPath: directory, selfInfo: { uin: '10001', uid: 'u_current' }, apis: { QzoneApi: { publishMsg: vi.fn().mockResolvedValue({ tid: 'modern-tid' }) }, WebApi: { uploadImageToQzone: vi.fn().mockResolvedValue('legacy-richval'), publishQzoneMsg: vi.fn().mockResolvedValue({ tid: 'legacy-tid' }), deleteQzoneMsg: vi.fn().mockResolvedValue(undefined), }, }, }; } afterEach(() => { for (const directory of tempDirs.splice(0)) { rmSync(directory, { recursive: true, force: true }); } }); describe('Qzone existing action compatibility', () => { it('keeps send_qzone_msg parameters and tid response for pure text', async () => { const core = createCore(); const action = new SendQzoneMsg(undefined as never, core as never); await expect(action._handle({ content: '朋友可见', ugc_right: '16', target_uins: ['10002'] })) .resolves.toEqual({ tid: 'modern-tid', time: 0 }); expect(core.apis.QzoneApi.publishMsg).toHaveBeenCalledExactlyOnceWith('朋友可见', 16, ['10002']); expect(core.apis.WebApi.publishQzoneMsg).not.toHaveBeenCalled(); }); it('does not retry an uncertain modern publish through the legacy CGI', async () => { const core = createCore(); core.apis.QzoneApi.publishMsg.mockRejectedValue(new Error('发表结果未知,请查询后确认')); const action = new SendQzoneMsg(undefined as never, core as never); await expect(action._handle({ content: '仅发送一次', images: [] })).rejects.toThrow('结果未知'); expect(core.apis.QzoneApi.publishMsg).toHaveBeenCalledTimes(1); expect(core.apis.WebApi.publishQzoneMsg).not.toHaveBeenCalled(); }); it('preserves decimal target identifiers without a lossy numeric conversion', async () => { const core = createCore(); const action = new SendQzoneMsg(undefined as never, core as never); await action._handle({ content: '指定好友', ugc_right: 16, target_uins: ['9007199254740993'] }); expect(core.apis.QzoneApi.publishMsg).toHaveBeenCalledExactlyOnceWith('指定好友', 16, ['9007199254740993']); }); it.each([{ images: [] }, { images: ['base64://aGVsbG8='] }])('rejects an invalid exclusion list before any write ($images)', async ({ images }) => { const core = createCore(); const action = new SendQzoneMsg(undefined as never, core as never); await expect(action._handle({ content: '排除指定好友', ugc_right: 128, target_uins: ['10002', 'invalid'], images })) .rejects.toThrow('target_uins'); expect(core.apis.QzoneApi.publishMsg).not.toHaveBeenCalled(); expect(core.apis.WebApi.uploadImageToQzone).not.toHaveBeenCalled(); expect(core.apis.WebApi.publishQzoneMsg).not.toHaveBeenCalled(); }); it('keeps legacy image publishing and cleans decoded images without deleting user files', async () => { const core = createCore(); const localImage = join(core.NapCatTempPath, 'user-image.png'); writeFileSync(localImage, 'user image'); const action = new SendQzoneMsg(undefined as never, core as never); await expect(action._handle({ content: '带图说说', images: ['base64://aGVsbG8=', pathToFileURL(localImage).href], ugc_right: 64, })).resolves.toEqual({ tid: 'legacy-tid', time: 0 }); expect(core.apis.QzoneApi.publishMsg).not.toHaveBeenCalled(); expect(core.apis.WebApi.uploadImageToQzone.mock.calls.map(args => args[0])).toEqual([ Buffer.from('hello').toString('base64'), Buffer.from('user image').toString('base64'), ]); expect(core.apis.WebApi.publishQzoneMsg).toHaveBeenCalledExactlyOnceWith( '带图说说', ['legacy-richval', 'legacy-richval'], 64, undefined ); expect(readdirSync(core.NapCatTempPath)).toEqual(['user-image.png']); expect(readFileSync(localImage, 'utf8')).toBe('user image'); }); it('cleans temporary images and does not publish after an upload failure', async () => { const core = createCore(); core.apis.WebApi.uploadImageToQzone.mockRejectedValue(new Error('upload failed')); const action = new SendQzoneMsg(undefined as never, core as never); await expect(action._handle({ content: '带图', images: ['base64://aGVsbG8='] })).rejects.toThrow('upload failed'); expect(core.apis.WebApi.publishQzoneMsg).not.toHaveBeenCalled(); expect(core.apis.QzoneApi.publishMsg).not.toHaveBeenCalled(); expect(readdirSync(core.NapCatTempPath)).toEqual([]); }); it('stops before upload and cleans decoded images if the account changes during image decoding', async () => { for (const change of [{ uin: '10002' }, { uid: 'u_other' }]) { const core = createCore(); const decode = fileUtils.uriToLocalFile; const decoder = vi.spyOn(fileUtils, 'uriToLocalFile').mockImplementationOnce(async (...args) => { const result = await decode(...args); Object.assign(core.selfInfo, change); return result; }); try { await expect(new SendQzoneMsg(undefined as never, core as never)._handle({ content: '带图', images: ['base64://aGVsbG8='], })).rejects.toThrow('QQ账号已切换'); expect(core.apis.WebApi.uploadImageToQzone).not.toHaveBeenCalled(); expect(core.apis.WebApi.publishQzoneMsg).not.toHaveBeenCalled(); expect(core.apis.QzoneApi.publishMsg).not.toHaveBeenCalled(); expect(readdirSync(core.NapCatTempPath)).toEqual([]); } finally { decoder.mockRestore(); } } }); it('stops the upload chain without publishing to a changed account and preserves user images', async () => { for (const change of [{ uin: '10002' }, { uid: 'u_other' }]) { const core = createCore(); const localImage = join(core.NapCatTempPath, 'user-image.png'); writeFileSync(localImage, 'user image'); core.apis.WebApi.uploadImageToQzone.mockImplementationOnce(async () => { Object.assign(core.selfInfo, change); return 'legacy-richval'; }); await expect(new SendQzoneMsg(undefined as never, core as never)._handle({ content: '带图', images: ['base64://aGVsbG8=', pathToFileURL(localImage).href], })).rejects.toThrow('QQ账号已切换'); expect(core.apis.WebApi.uploadImageToQzone).toHaveBeenCalledTimes(1); expect(core.apis.WebApi.publishQzoneMsg).not.toHaveBeenCalled(); expect(core.apis.QzoneApi.publishMsg).not.toHaveBeenCalled(); expect(readdirSync(core.NapCatTempPath)).toEqual(['user-image.png']); expect(readFileSync(localImage, 'utf8')).toBe('user image'); } }); it('validates existing selective visibility before uploading or publishing', async () => { const core = createCore(); const action = new SendQzoneMsg(undefined as never, core as never); await expect(action._handle({ content: '指定好友', ugc_right: 128, images: ['base64://aGVsbG8='] })) .rejects.toThrow('target_uins 不能为空'); expect(core.apis.QzoneApi.publishMsg).not.toHaveBeenCalled(); expect(core.apis.WebApi.uploadImageToQzone).not.toHaveBeenCalled(); }); it('keeps delete_qzone_msg on its existing tid-based CGI path', async () => { const core = createCore(); const action = new DeleteQzoneMsg(undefined as never, core as never); await expect(action._handle({ tid: 'existing-tid' })).resolves.toBeNull(); expect(core.apis.WebApi.deleteQzoneMsg).toHaveBeenCalledExactlyOnceWith('existing-tid'); }); });