SnowCat 雪猫底层全源码:NapCat 4.18.28 底座 + SnowLuma 能力移植 + V8 通道适配 9.9.37
26 包 monorepo(TS 全源码): - snowcat-core: NT 内核 API 封装 + listener(含 9.9.37 六回调适配)+ Packet 发包 - snowcat-onebot: OneBot v11 全动作 + MCP Streamable HTTP 适配器 + 稳定线 - snowcat-webui-frontend/backend: 雪猫控制台(React+HeroUI) - snowcat-shell: 单文件 bundle 出口(vite inlineDynamicImports) - snowcat-native: napi2native / MoeHoo / ffmpeg / pty 运行时 - 56 项 SnowLuma 独门动作移植(qzone 全家桶/群相册/装扮/闪照/收藏) - 工具件: 解包器/启动器/便携制作器/doctor GitHub 关联已全量指向 DandanLLab/SnowCatQQ
This commit is contained in:
commit
b9655a5ab1
1048 files changed
+130851
No files matched your search
@@ -0,0 +1,3 @@
|
||||
# NapCat-Test
|
||||
## 测试方向
|
||||
测试过程不涉及账号登录
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
applyGroupManagementSettings,
|
||||
createGroupDetailInfoV2Param,
|
||||
createGroupManagementRequests,
|
||||
} from '../snowcat-core/data/group';
|
||||
import { assertGroupManagementResults } from '../snowcat-onebot/action/group/GroupOperationHelpers';
|
||||
|
||||
describe('NTQQ group operations', () => {
|
||||
test('reports the exact native setting that failed', () => {
|
||||
expect(() => assertGroupManagementResults('设置群成员功能权限', [{
|
||||
setting: 'allow_member_create_group',
|
||||
result: { result: 1006, errMsg: '未知错误' },
|
||||
}])).toThrow(/allow_member_create_group.*1006.*未知错误/);
|
||||
expect(() => assertGroupManagementResults('设置群成员功能权限', []))
|
||||
.not.toThrow();
|
||||
});
|
||||
|
||||
test('builds the NTQQ permission masks without changing unrelated flags', async () => {
|
||||
const request = createGroupDetailInfoV2Param('123456');
|
||||
expect(applyGroupManagementSettings(request, {
|
||||
memberInvite: 'no_approval',
|
||||
allowMemberUploadAlbum: false,
|
||||
allowMemberTemporarySession: true,
|
||||
allowMemberCreateGroup: false,
|
||||
newMembersSeeRecentHistory: true,
|
||||
})).toBe(true);
|
||||
expect(request.groupCode).toBe('123456');
|
||||
expect(request.filter).toMatchObject({
|
||||
allowMemberInvite: 1,
|
||||
appPrivilegeFlag: 1,
|
||||
appPrivilegeMask: 1,
|
||||
groupFlagExt4: 1,
|
||||
groupFlagExt4Mask: 1,
|
||||
});
|
||||
expect(request.modifyInfo).toMatchObject({
|
||||
allowMemberInvite: 1,
|
||||
appPrivilegeFlag: 0x00108001,
|
||||
appPrivilegeMask: 0x06118001,
|
||||
groupFlagExt3: 0,
|
||||
groupFlagExt3Mask: 0,
|
||||
groupFlagExt4: 0x4,
|
||||
groupFlagExt4Mask: 0x4,
|
||||
});
|
||||
});
|
||||
|
||||
test('maps every member invite policy to the native app privilege value', async () => {
|
||||
const expected = {
|
||||
disabled: 0x04000000,
|
||||
require_approval: 0,
|
||||
no_approval: 0x00100000,
|
||||
no_approval_under_100: 0x02000000,
|
||||
} as const;
|
||||
|
||||
for (const [memberInvite, privilegeFlag] of Object.entries(expected)) {
|
||||
const request = createGroupDetailInfoV2Param('123456');
|
||||
applyGroupManagementSettings(request, { memberInvite: memberInvite as keyof typeof expected });
|
||||
expect(request.modifyInfo.allowMemberInvite).toBe(memberInvite === 'disabled' ? 0 : 1);
|
||||
expect(request.modifyInfo.appPrivilegeFlag).toBe(privilegeFlag);
|
||||
expect(request.modifyInfo.appPrivilegeMask).toBe(0x06100000);
|
||||
}
|
||||
});
|
||||
|
||||
test('uses NTQQ privilege operation type 8 for album, temporary-session, and create-group flags', () => {
|
||||
const requests = createGroupManagementRequests('123456', {
|
||||
memberInvite: 'require_approval',
|
||||
allowMemberUploadAlbum: true,
|
||||
allowMemberTemporarySession: true,
|
||||
allowMemberCreateGroup: true,
|
||||
}, 0x18001);
|
||||
|
||||
expect(requests.map(request => request.operationType)).toEqual([0, 8, 8, 8]);
|
||||
expect(requests.map(request => request.setting)).toEqual([
|
||||
'member_invite',
|
||||
'allow_member_upload_album',
|
||||
'allow_member_temporary_session',
|
||||
'allow_member_create_group',
|
||||
]);
|
||||
expect(requests.slice(1).map(request => request.param.modifyInfo.appPrivilegeMask))
|
||||
.toEqual([0x1, 0x10000, 0x8000]);
|
||||
expect(requests[1]?.param.modifyInfo).toMatchObject({
|
||||
appPrivilegeFlag: 0x18000,
|
||||
appPrivilegeMask: 0x1,
|
||||
groupFlagExt3: 0,
|
||||
groupFlagExt3Mask: 0,
|
||||
});
|
||||
expect(requests[2]?.param.modifyInfo).toMatchObject({
|
||||
appPrivilegeFlag: 0x8000,
|
||||
appPrivilegeMask: 0x10000,
|
||||
groupFlagExt3: 0,
|
||||
groupFlagExt3Mask: 0,
|
||||
});
|
||||
expect(requests[3]?.param.modifyInfo).toMatchObject({
|
||||
appPrivilegeFlag: 0,
|
||||
appPrivilegeMask: 0x8000,
|
||||
groupFlagExt3: 0,
|
||||
groupFlagExt3Mask: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves the complete privilege value when clearing Linux NTQQ permission bits', () => {
|
||||
const requests = createGroupManagementRequests('123456', {
|
||||
allowMemberUploadAlbum: false,
|
||||
allowMemberTemporarySession: false,
|
||||
allowMemberCreateGroup: false,
|
||||
}, 0);
|
||||
|
||||
expect(requests.map(request => request.param.modifyInfo.appPrivilegeFlag))
|
||||
.toEqual([0x1, 0x10001, 0x18001]);
|
||||
expect(requests.map(request => request.param.modifyInfo.appPrivilegeMask))
|
||||
.toEqual([0x1, 0x10000, 0x8000]);
|
||||
});
|
||||
|
||||
test('skips privilege requests whose effective value is already current', () => {
|
||||
const requests = createGroupManagementRequests('123456', {
|
||||
allowMemberUploadAlbum: true,
|
||||
allowMemberTemporarySession: true,
|
||||
allowMemberCreateGroup: true,
|
||||
newMembersSeeRecentHistory: true,
|
||||
}, 0x00100000, 0x4);
|
||||
|
||||
expect(requests).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { NapProtoMsg } from 'snowcat-protobuf';
|
||||
import { OidbSvcTrpcTcpBase } from '@/snowcat-core/packet/transformer/proto/oidb/OidbBase';
|
||||
import { OidbSvcTrpcTcp0XF90 } from '@/snowcat-core/packet/transformer/proto/oidb/Oidb.0xf90';
|
||||
import SetGroupTodo from '@/snowcat-core/packet/transformer/action/SetGroupTodo';
|
||||
import CompleteGroupTodo from '@/snowcat-core/packet/transformer/action/CompleteGroupTodo';
|
||||
import CancelGroupTodo from '@/snowcat-core/packet/transformer/action/CancelGroupTodo';
|
||||
|
||||
vi.mock('@/snowcat-core/packet/transformer/base', () => ({
|
||||
PacketTransformer: class PacketTransformer {
|
||||
protected msgBuilder: unknown;
|
||||
|
||||
constructor () {
|
||||
this.msgBuilder = undefined;
|
||||
}
|
||||
},
|
||||
PacketBufBuilder: (str: Uint8Array) => Buffer.from(str),
|
||||
}));
|
||||
|
||||
const decodePacket = (data: Buffer) => {
|
||||
const base = new NapProtoMsg(OidbSvcTrpcTcpBase).decode(data);
|
||||
const body = new NapProtoMsg(OidbSvcTrpcTcp0XF90).decode(base.body);
|
||||
return { base, body };
|
||||
};
|
||||
|
||||
describe('Group todo packet builder', () => {
|
||||
test('builds OIDB 0xF90 subCommand 1 for set', () => {
|
||||
const packet = SetGroupTodo.build(123456, '789');
|
||||
const { base, body } = decodePacket(packet.data);
|
||||
|
||||
expect(packet.cmd).toBe('OidbSvcTrpcTcp.0xF90_1');
|
||||
expect(base.command).toBe(0xF90);
|
||||
expect(base.subCommand).toBe(1);
|
||||
expect(body.groupUin).toBe(123456);
|
||||
expect(body.msgSeq).toBe('789');
|
||||
});
|
||||
|
||||
test('builds OIDB 0xF90 subCommand 2 for complete', () => {
|
||||
const packet = CompleteGroupTodo.build(123456, '789');
|
||||
const { base, body } = decodePacket(packet.data);
|
||||
|
||||
expect(packet.cmd).toBe('OidbSvcTrpcTcp.0xF90_2');
|
||||
expect(base.command).toBe(0xF90);
|
||||
expect(base.subCommand).toBe(2);
|
||||
expect(body.groupUin).toBe(123456);
|
||||
expect(body.msgSeq).toBe('789');
|
||||
});
|
||||
|
||||
test('builds OIDB 0xF90 subCommand 3 for cancel', () => {
|
||||
const packet = CancelGroupTodo.build(123456, '789');
|
||||
const { base, body } = decodePacket(packet.data);
|
||||
|
||||
expect(packet.cmd).toBe('OidbSvcTrpcTcp.0xF90_3');
|
||||
expect(base.command).toBe(0xF90);
|
||||
expect(base.subCommand).toBe(3);
|
||||
expect(body.groupUin).toBe(123456);
|
||||
expect(body.msgSeq).toBe('789');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
detectImageTypeFromBuffer,
|
||||
imageSizeFromBuffer,
|
||||
imageSizeFromBufferFallBack,
|
||||
imageSizeFromFile,
|
||||
matchMagic,
|
||||
ImageType,
|
||||
} from '@/snowcat-image-size/src';
|
||||
|
||||
// resource 目录路径
|
||||
const resourceDir = path.resolve(__dirname, '../snowcat-image-size/resource');
|
||||
|
||||
// 测试用的 Buffer 数据
|
||||
const testBuffers = {
|
||||
// PNG 测试图片 (100x200)
|
||||
png: Buffer.from([
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
||||
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0xC8,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
]),
|
||||
|
||||
// JPEG 测试图片 (320x240)
|
||||
jpeg: Buffer.from([
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10,
|
||||
0x4A, 0x46, 0x49, 0x46, 0x00,
|
||||
0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
|
||||
0xFF, 0xC0, 0x00, 0x0B, 0x08,
|
||||
0x00, 0xF0, 0x01, 0x40, 0x03, 0x01, 0x22, 0x00,
|
||||
]),
|
||||
|
||||
// BMP 测试图片 (640x480)
|
||||
bmp: (() => {
|
||||
const buf = Buffer.alloc(54);
|
||||
buf.write('BM', 0);
|
||||
buf.writeUInt32LE(54, 2);
|
||||
buf.writeUInt32LE(0, 6);
|
||||
buf.writeUInt32LE(54, 10);
|
||||
buf.writeUInt32LE(40, 14);
|
||||
buf.writeUInt32LE(640, 18);
|
||||
buf.writeUInt32LE(480, 22);
|
||||
buf.writeUInt16LE(1, 26);
|
||||
buf.writeUInt16LE(24, 28);
|
||||
return buf;
|
||||
})(),
|
||||
|
||||
// GIF87a 测试图片 (800x600)
|
||||
gif87a: Buffer.from([
|
||||
0x47, 0x49, 0x46, 0x38, 0x37, 0x61,
|
||||
0x20, 0x03, 0x58, 0x02, 0x00, 0x00, 0x00,
|
||||
]),
|
||||
|
||||
// GIF89a 测试图片 (1024x768)
|
||||
gif89a: Buffer.from([
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
|
||||
0x00, 0x04, 0x00, 0x03, 0x00, 0x00, 0x00,
|
||||
]),
|
||||
|
||||
// WebP VP8 测试图片 (1920x1080)
|
||||
webpVP8: (() => {
|
||||
const buf = Buffer.alloc(32);
|
||||
buf.write('RIFF', 0);
|
||||
buf.writeUInt32LE(24, 4);
|
||||
buf.write('WEBP', 8);
|
||||
buf.write('VP8 ', 12);
|
||||
buf.writeUInt32LE(14, 16);
|
||||
buf.writeUInt8(0x9D, 20);
|
||||
buf.writeUInt8(0x01, 21);
|
||||
buf.writeUInt8(0x2A, 22);
|
||||
buf.writeUInt16LE(1920 & 0x3FFF, 26);
|
||||
buf.writeUInt16LE(1080 & 0x3FFF, 28);
|
||||
return buf;
|
||||
})(),
|
||||
|
||||
// WebP VP8L 测试图片 (256x128)
|
||||
webpVP8L: (() => {
|
||||
const buf = Buffer.alloc(32);
|
||||
buf.write('RIFF', 0);
|
||||
buf.writeUInt32LE(24, 4);
|
||||
buf.write('WEBP', 8);
|
||||
buf.write('VP8L', 12);
|
||||
buf.writeUInt32LE(10, 16);
|
||||
buf.writeUInt8(0x2F, 20);
|
||||
const vp8lBits = (256 - 1) | ((128 - 1) << 14);
|
||||
buf.writeUInt32LE(vp8lBits, 21);
|
||||
return buf;
|
||||
})(),
|
||||
|
||||
// WebP VP8X 测试图片 (512x384)
|
||||
webpVP8X: (() => {
|
||||
const buf = Buffer.alloc(32);
|
||||
buf.write('RIFF', 0);
|
||||
buf.writeUInt32LE(24, 4);
|
||||
buf.write('WEBP', 8);
|
||||
buf.write('VP8X', 12);
|
||||
buf.writeUInt32LE(10, 16);
|
||||
buf.writeUInt8((512 - 1) & 0xFF, 24);
|
||||
buf.writeUInt8(((512 - 1) >> 8) & 0xFF, 25);
|
||||
buf.writeUInt8(((512 - 1) >> 16) & 0xFF, 26);
|
||||
buf.writeUInt8((384 - 1) & 0xFF, 27);
|
||||
buf.writeUInt8(((384 - 1) >> 8) & 0xFF, 28);
|
||||
buf.writeUInt8(((384 - 1) >> 16) & 0xFF, 29);
|
||||
return buf;
|
||||
})(),
|
||||
|
||||
// TIFF Little Endian 测试图片
|
||||
tiffLE: Buffer.from([
|
||||
0x49, 0x49, 0x2A, 0x00, // II + magic
|
||||
0x08, 0x00, 0x00, 0x00, // IFD offset = 8
|
||||
0x02, 0x00, // 2 entries
|
||||
// Entry 1: ImageWidth = 100
|
||||
0x00, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00,
|
||||
// Entry 2: ImageHeight = 200
|
||||
0x01, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0xC8, 0x00, 0x00, 0x00,
|
||||
]),
|
||||
|
||||
// TIFF Big Endian 测试图片
|
||||
tiffBE: Buffer.from([
|
||||
0x4D, 0x4D, 0x00, 0x2A, // MM + magic
|
||||
0x00, 0x00, 0x00, 0x08, // IFD offset = 8
|
||||
0x00, 0x02, // 2 entries
|
||||
// Entry 1: ImageWidth = 100
|
||||
0x01, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x64, 0x00, 0x00,
|
||||
// Entry 2: ImageHeight = 200
|
||||
0x01, 0x01, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0xC8, 0x00, 0x00,
|
||||
]),
|
||||
|
||||
invalid: Buffer.from('This is not an image file'),
|
||||
empty: Buffer.alloc(0),
|
||||
};
|
||||
|
||||
describe('snowcat-image-size', () => {
|
||||
describe('matchMagic', () => {
|
||||
it('should match magic bytes at the beginning', () => {
|
||||
const buffer = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(true);
|
||||
});
|
||||
|
||||
it('should match magic bytes at offset', () => {
|
||||
const buffer = Buffer.from([0x00, 0x00, 0x89, 0x50, 0x4E, 0x47]);
|
||||
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47], 2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-matching magic', () => {
|
||||
const buffer = Buffer.from([0x00, 0x00, 0x00, 0x00]);
|
||||
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for buffer too short', () => {
|
||||
const buffer = Buffer.from([0x89, 0x50]);
|
||||
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for offset beyond buffer', () => {
|
||||
const buffer = Buffer.from([0x89, 0x50, 0x4E, 0x47]);
|
||||
expect(matchMagic(buffer, [0x89, 0x50], 10)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectImageTypeFromBuffer', () => {
|
||||
it('should detect PNG image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.png)).toBe(ImageType.PNG);
|
||||
});
|
||||
|
||||
it('should detect JPEG image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.jpeg)).toBe(ImageType.JPEG);
|
||||
});
|
||||
|
||||
it('should detect BMP image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.bmp)).toBe(ImageType.BMP);
|
||||
});
|
||||
|
||||
it('should detect GIF87a image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.gif87a)).toBe(ImageType.GIF);
|
||||
});
|
||||
|
||||
it('should detect GIF89a image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.gif89a)).toBe(ImageType.GIF);
|
||||
});
|
||||
|
||||
it('should detect WebP VP8 image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.webpVP8)).toBe(ImageType.WEBP);
|
||||
});
|
||||
|
||||
it('should detect WebP VP8L image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.webpVP8L)).toBe(ImageType.WEBP);
|
||||
});
|
||||
|
||||
it('should detect WebP VP8X image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.webpVP8X)).toBe(ImageType.WEBP);
|
||||
});
|
||||
|
||||
it('should detect TIFF Little Endian image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.tiffLE)).toBe(ImageType.TIFF);
|
||||
});
|
||||
|
||||
it('should detect TIFF Big Endian image type', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.tiffBE)).toBe(ImageType.TIFF);
|
||||
});
|
||||
|
||||
it('should return UNKNOWN for invalid data', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.invalid)).toBe(ImageType.UNKNOWN);
|
||||
});
|
||||
|
||||
it('should return UNKNOWN for empty buffer', () => {
|
||||
expect(detectImageTypeFromBuffer(testBuffers.empty)).toBe(ImageType.UNKNOWN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('imageSizeFromBuffer', () => {
|
||||
it('should parse PNG image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.png)).toEqual({ width: 100, height: 200 });
|
||||
});
|
||||
|
||||
it('should parse JPEG image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.jpeg)).toEqual({ width: 320, height: 240 });
|
||||
});
|
||||
|
||||
it('should parse BMP image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.bmp)).toEqual({ width: 640, height: 480 });
|
||||
});
|
||||
|
||||
it('should parse GIF87a image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.gif87a)).toEqual({ width: 800, height: 600 });
|
||||
});
|
||||
|
||||
it('should parse GIF89a image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.gif89a)).toEqual({ width: 1024, height: 768 });
|
||||
});
|
||||
|
||||
it('should parse WebP VP8 image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.webpVP8)).toEqual({ width: 1920, height: 1080 });
|
||||
});
|
||||
|
||||
it('should parse WebP VP8L image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.webpVP8L)).toEqual({ width: 256, height: 128 });
|
||||
});
|
||||
|
||||
it('should parse WebP VP8X image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.webpVP8X)).toEqual({ width: 512, height: 384 });
|
||||
});
|
||||
|
||||
it('should parse TIFF Little Endian image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.tiffLE)).toEqual({ width: 100, height: 200 });
|
||||
});
|
||||
|
||||
it('should parse TIFF Big Endian image size correctly', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.tiffBE)).toEqual({ width: 100, height: 200 });
|
||||
});
|
||||
|
||||
it('should return undefined for invalid data', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.invalid)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for empty buffer', async () => {
|
||||
expect(await imageSizeFromBuffer(testBuffers.empty)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('imageSizeFromBufferFallBack', () => {
|
||||
it('should return actual size for valid image', async () => {
|
||||
expect(await imageSizeFromBufferFallBack(testBuffers.png)).toEqual({ width: 100, height: 200 });
|
||||
});
|
||||
|
||||
it('should return default fallback for invalid data', async () => {
|
||||
expect(await imageSizeFromBufferFallBack(testBuffers.invalid)).toEqual({ width: 1024, height: 1024 });
|
||||
});
|
||||
|
||||
it('should return custom fallback for invalid data', async () => {
|
||||
expect(await imageSizeFromBufferFallBack(testBuffers.invalid, { width: 500, height: 300 })).toEqual({ width: 500, height: 300 });
|
||||
});
|
||||
|
||||
it('should return default fallback for empty buffer', async () => {
|
||||
expect(await imageSizeFromBufferFallBack(testBuffers.empty)).toEqual({ width: 1024, height: 1024 });
|
||||
});
|
||||
|
||||
it('should return custom fallback for empty buffer', async () => {
|
||||
expect(await imageSizeFromBufferFallBack(testBuffers.empty, { width: 800, height: 600 })).toEqual({ width: 800, height: 600 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImageType enum', () => {
|
||||
it('should have correct enum values', () => {
|
||||
expect(ImageType.JPEG).toBe('jpeg');
|
||||
expect(ImageType.PNG).toBe('png');
|
||||
expect(ImageType.BMP).toBe('bmp');
|
||||
expect(ImageType.GIF).toBe('gif');
|
||||
expect(ImageType.WEBP).toBe('webp');
|
||||
expect(ImageType.TIFF).toBe('tiff');
|
||||
expect(ImageType.UNKNOWN).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real image files from resource directory', () => {
|
||||
it('should detect and parse test-20x20.jpg', async () => {
|
||||
const filePath = path.join(resourceDir, 'test-20x20.jpg');
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.JPEG);
|
||||
const size = await imageSizeFromBuffer(buffer);
|
||||
expect(size).toEqual({ width: 20, height: 20 });
|
||||
});
|
||||
|
||||
it('should detect and parse test-20x20.png', async () => {
|
||||
const filePath = path.join(resourceDir, 'test-20x20.png');
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.PNG);
|
||||
const size = await imageSizeFromBuffer(buffer);
|
||||
expect(size).toEqual({ width: 20, height: 20 });
|
||||
});
|
||||
|
||||
it('should detect and parse test-20x20.tiff', async () => {
|
||||
const filePath = path.join(resourceDir, 'test-20x20.tiff');
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.TIFF);
|
||||
const size = await imageSizeFromBuffer(buffer);
|
||||
expect(size).toEqual({ width: 20, height: 20 });
|
||||
});
|
||||
|
||||
it('should detect and parse test-20x20.webp', async () => {
|
||||
const filePath = path.join(resourceDir, 'test-20x20.webp');
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.WEBP);
|
||||
const size = await imageSizeFromBuffer(buffer);
|
||||
expect(size).toEqual({ width: 20, height: 20 });
|
||||
});
|
||||
|
||||
it('should detect and parse test-490x498.gif', async () => {
|
||||
const filePath = path.join(resourceDir, 'test-490x498.gif');
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.GIF);
|
||||
const size = await imageSizeFromBuffer(buffer);
|
||||
expect(size).toEqual({ width: 490, height: 498 });
|
||||
});
|
||||
|
||||
it('should parse real images using imageSizeFromFile', async () => {
|
||||
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.jpg'))).toEqual({ width: 20, height: 20 });
|
||||
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.png'))).toEqual({ width: 20, height: 20 });
|
||||
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.tiff'))).toEqual({ width: 20, height: 20 });
|
||||
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.webp'))).toEqual({ width: 20, height: 20 });
|
||||
expect(await imageSizeFromFile(path.join(resourceDir, 'test-490x498.gif'))).toEqual({ width: 490, height: 498 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "snowcat-test",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.0.9"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.34.41",
|
||||
"snowcat-core": "workspace:*",
|
||||
"snowcat-image-size": "workspace:*",
|
||||
"snowcat-onebot": "workspace:*",
|
||||
"snowcat-rpc": "workspace:*",
|
||||
"snowcat-protobuf": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { QQBasicInfoWrapper } from '@/snowcat-core/helper/qq-basic-info';
|
||||
|
||||
vi.mock('@/snowcat-core/index', () => ({ getMajorPath: vi.fn() }));
|
||||
|
||||
describe('QQ AppID lookup', () => {
|
||||
let info: QQBasicInfoWrapper;
|
||||
const archDescriptor = Object.getOwnPropertyDescriptor(process, 'arch')!;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'arch', { ...archDescriptor, value: 'x64' });
|
||||
info = Object.create(QQBasicInfoWrapper.prototype);
|
||||
info.context = { logger: { log: vi.fn() } } as QQBasicInfoWrapper['context'];
|
||||
vi.spyOn(info, 'getFullQQVersion');
|
||||
vi.spyOn(info, 'getAppidV2ByMajor').mockReturnValue('537999999');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'arch', archDescriptor);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['3.2.30-50969', 'x64', 537376344],
|
||||
['3.2.30-50969', 'arm64', 537376345],
|
||||
['3.2.32-52194', 'x64', 537379447],
|
||||
['3.2.32-52194', 'arm64', 537379448],
|
||||
['3.2.33-52892', 'x64', 537382855],
|
||||
['3.2.33-52892', 'arm64', 537382856],
|
||||
] as const)('uses the scanned AppID for Linux %s %s', (version, arch, appid) => {
|
||||
Object.defineProperty(process, 'arch', { ...archDescriptor, value: arch });
|
||||
vi.mocked(info.getFullQQVersion).mockReturnValue(version);
|
||||
|
||||
expect(info.getAppidV2()).toEqual({
|
||||
appid,
|
||||
qua: `V1_LNX_NQ_${version.replace('-', '_')}_GW_B`,
|
||||
});
|
||||
expect(info.getAppidV2ByMajor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['9.9.33-52230', 537379411],
|
||||
['9.9.35-52892', 537382819],
|
||||
] as const)('finds Windows %s by its actual package version', (version, appid) => {
|
||||
vi.mocked(info.getFullQQVersion).mockReturnValue(version);
|
||||
|
||||
expect(info.getAppidV2()).toEqual({
|
||||
appid,
|
||||
qua: `V1_WIN_NQ_${version.replace('-', '_')}_GW_B`,
|
||||
});
|
||||
expect(info.getAppidV2ByMajor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves version-only entries when an architecture override is absent', () => {
|
||||
Object.defineProperty(process, 'arch', { ...archDescriptor, value: 'arm64' });
|
||||
vi.mocked(info.getFullQQVersion).mockReturnValue('3.2.29-49738');
|
||||
|
||||
expect(info.getAppidV2()).toEqual({
|
||||
appid: 537355867,
|
||||
qua: 'V1_LNX_NQ_3.2.29_49738_GW_B',
|
||||
});
|
||||
expect(info.getAppidV2ByMajor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still scans major.node for an unknown version', () => {
|
||||
vi.mocked(info.getFullQQVersion).mockReturnValue('3.2.99-99999');
|
||||
vi.spyOn(info, 'getQUAFallback').mockReturnValue('fallback-qua');
|
||||
|
||||
expect(info.getAppidV2()).toEqual({ appid: '537999999', qua: 'fallback-qua' });
|
||||
expect(info.getAppidV2ByMajor).toHaveBeenCalledWith('3.2.99-99999');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
buildQzoneDeleteBody,
|
||||
buildQzonePublishBody,
|
||||
buildQzoneUploadImageBody,
|
||||
parseQzoneDeleteResponse,
|
||||
parseQzonePublishResponse,
|
||||
parseQzoneUploadImageResponseText,
|
||||
toQzoneRichval,
|
||||
} from '@/snowcat-core/data/qzone';
|
||||
|
||||
describe('Qzone data helpers', () => {
|
||||
test('buildQzoneUploadImageBody encodes required fields', () => {
|
||||
const body = buildQzoneUploadImageBody({
|
||||
uin: '123456',
|
||||
skey: 'sk',
|
||||
pskey: 'pk',
|
||||
g_tk: '999',
|
||||
base64: 'AAAA',
|
||||
});
|
||||
const params = new URLSearchParams(body);
|
||||
expect(params.get('uin')).toBe('123456');
|
||||
expect(params.get('skey')).toBe('sk');
|
||||
expect(params.get('p_skey')).toBe('pk');
|
||||
expect(params.get('picfile')).toBe('AAAA');
|
||||
expect(params.get('uploadtype')).toBe('1');
|
||||
expect(params.get('albumtype')).toBe('7');
|
||||
expect(params.get('base64')).toBe('1');
|
||||
expect(params.get('qzreferrer')).toContain('123456');
|
||||
});
|
||||
|
||||
test('parseQzoneUploadImageResponseText parses valid callback wrapper', () => {
|
||||
const raw = 'frameElement.callback({"code":0,"data":{"albumid":"a1","lloc":"l1","type":"22","height":"100","width":"200"}});</script>';
|
||||
const parsed = parseQzoneUploadImageResponseText(raw);
|
||||
expect(parsed.code).toBe(0);
|
||||
expect(parsed.data?.albumid).toBe('a1');
|
||||
expect(parsed.data?.lloc).toBe('l1');
|
||||
});
|
||||
|
||||
test('parseQzoneUploadImageResponseText throws on malformed response', () => {
|
||||
expect(() => parseQzoneUploadImageResponseText('not a valid response')).toThrow();
|
||||
});
|
||||
|
||||
test('toQzoneRichval builds expected format', () => {
|
||||
const richval = toQzoneRichval({ albumid: 'a1', lloc: 'l1', type: '22', height: '100', width: '200' });
|
||||
expect(richval).toBe(',a1,l1,l1,22,100,200,,100,200');
|
||||
});
|
||||
|
||||
test('buildQzonePublishBody without images omits richval/richtype', () => {
|
||||
const body = buildQzonePublishBody({
|
||||
hostuin: '123456',
|
||||
content: 'hello world',
|
||||
ugcRight: 1,
|
||||
});
|
||||
const params = new URLSearchParams(body);
|
||||
expect(params.get('con')).toBe('hello world');
|
||||
expect(params.get('ugc_right')).toBe('1');
|
||||
expect(params.has('richval')).toBe(false);
|
||||
expect(params.has('richtype')).toBe(false);
|
||||
expect(params.has('allow_uins')).toBe(false);
|
||||
});
|
||||
|
||||
test('buildQzonePublishBody with images and allow_uins sets richtype and who', () => {
|
||||
const body = buildQzonePublishBody({
|
||||
hostuin: '123456',
|
||||
content: 'with pic',
|
||||
richval: ',a1,l1,l1,22,100,200,,100,200\t,a2,l2,l2,22,100,200,,100,200',
|
||||
ugcRight: 16,
|
||||
allowUins: '10001|10002',
|
||||
});
|
||||
const params = new URLSearchParams(body);
|
||||
expect(params.get('richtype')).toBe('1');
|
||||
expect(params.get('richval')).toContain('\t');
|
||||
expect(params.get('ugc_right')).toBe('16');
|
||||
expect(params.get('allow_uins')).toBe('10001|10002');
|
||||
expect(params.get('who')).toBe('1');
|
||||
});
|
||||
|
||||
test('parseQzonePublishResponse extracts tid on success', () => {
|
||||
const result = parseQzonePublishResponse({ subcode: 0, tid: 'tid123' });
|
||||
expect(result.tid).toBe('tid123');
|
||||
});
|
||||
|
||||
test('parseQzonePublishResponse prefers t1_tid over tid', () => {
|
||||
const result = parseQzonePublishResponse({ subcode: 0, tid: 'tid123', t1_tid: 't1tid456' });
|
||||
expect(result.tid).toBe('t1tid456');
|
||||
});
|
||||
|
||||
test('parseQzonePublishResponse throws when subcode indicates failure', () => {
|
||||
expect(() => parseQzonePublishResponse({ subcode: -200, message: '权限不足' })).toThrow('权限不足');
|
||||
});
|
||||
|
||||
test('parseQzonePublishResponse throws when tid missing', () => {
|
||||
expect(() => parseQzonePublishResponse({ subcode: 0 })).toThrow();
|
||||
});
|
||||
|
||||
test('buildQzoneDeleteBody encodes required fields', () => {
|
||||
const body = buildQzoneDeleteBody({ hostuin: '123456', tid: 'tid123' });
|
||||
const params = new URLSearchParams(body);
|
||||
expect(params.get('hostuin')).toBe('123456');
|
||||
expect(params.get('tid')).toBe('tid123');
|
||||
expect(params.get('t1_source')).toBe('1');
|
||||
expect(params.get('code_version')).toBe('1');
|
||||
expect(params.get('format')).toBe('json');
|
||||
expect(params.get('qzreferrer')).toContain('123456');
|
||||
});
|
||||
|
||||
test('parseQzoneDeleteResponse succeeds when subcode is 0', () => {
|
||||
const result = parseQzoneDeleteResponse({ subcode: 0 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test('parseQzoneDeleteResponse throws when subcode indicates failure', () => {
|
||||
expect(() => parseQzoneDeleteResponse({ subcode: -200, message: '权限不足' })).toThrow('权限不足');
|
||||
});
|
||||
|
||||
test('parseQzoneDeleteResponse throws with default message when subcode indicates failure without message', () => {
|
||||
expect(() => parseQzoneDeleteResponse({ subcode: -200 })).toThrow('subcode=-200');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { ChatType, NTMsgType, type RawMessage } from 'snowcat-core';
|
||||
import { NapCatOneBot11Adapter } from '@/snowcat-onebot/index';
|
||||
|
||||
vi.mock('../snowcat-webui-backend/index', () => ({
|
||||
pendingTokenToSend: undefined,
|
||||
}));
|
||||
|
||||
vi.mock('../snowcat-webui-backend/src/helper/Data', () => ({
|
||||
WebUiDataRuntime: {},
|
||||
}));
|
||||
|
||||
vi.mock('@/snowcat-onebot/config', () => ({
|
||||
OB11ConfigLoader: class {},
|
||||
}));
|
||||
|
||||
vi.mock('@/snowcat-onebot/network', () => ({
|
||||
OB11HttpClientAdapter: class {},
|
||||
OB11WebSocketClientAdapter: class {},
|
||||
OB11NetworkManager: class {},
|
||||
OB11NetworkReloadType: {},
|
||||
OB11HttpServerAdapter: class {},
|
||||
OB11WebSocketServerAdapter: class {},
|
||||
}));
|
||||
|
||||
vi.mock('@/snowcat-onebot/api', () => ({
|
||||
OneBotFriendApi: class {},
|
||||
OneBotGroupApi: class {},
|
||||
OneBotMsgApi: class {},
|
||||
OneBotQuickActionApi: class {},
|
||||
OneBotUserApi: class {},
|
||||
}));
|
||||
|
||||
vi.mock('@/snowcat-onebot/action', () => ({
|
||||
createActionMap: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/snowcat-onebot/network/adapter', () => ({
|
||||
IOB11NetworkAdapter: class {},
|
||||
}));
|
||||
|
||||
vi.mock('@/snowcat-onebot/network/http-server-sse', () => ({
|
||||
OB11HttpSSEServerAdapter: class {},
|
||||
}));
|
||||
|
||||
vi.mock('@/snowcat-onebot/network/plugin-manger', () => ({
|
||||
OB11PluginMangerAdapter: class {},
|
||||
}));
|
||||
|
||||
vi.mock('@/snowcat-onebot/api/file', () => ({
|
||||
OneBotFileApi: class {},
|
||||
}));
|
||||
|
||||
interface TestMsgListener {
|
||||
onMsgRecall (chatType: ChatType, uid: string, msgSeq: string): Promise<void> | void;
|
||||
onMsgInfoListUpdate (messages: RawMessage[]): Promise<void> | void;
|
||||
}
|
||||
|
||||
const friendUid = 'u_friend';
|
||||
const friendUin = '123456';
|
||||
const recalledMsgId = 'recalled-message-id';
|
||||
const recalledMsgSeq = '100';
|
||||
const botUin = '999999';
|
||||
|
||||
function createMessage (overrides: Partial<RawMessage>): RawMessage {
|
||||
return {
|
||||
msgId: 'message-id',
|
||||
msgRandom: '1',
|
||||
msgSeq: '101',
|
||||
cntSeq: '101',
|
||||
chatType: ChatType.KCHATTYPEC2C,
|
||||
msgType: NTMsgType.KMSGTYPEUNKNOWN,
|
||||
subMsgType: 0,
|
||||
sendStatus: 2,
|
||||
senderUid: friendUid,
|
||||
senderUin: friendUin,
|
||||
peerUid: friendUid,
|
||||
peerUin: friendUin,
|
||||
peerName: 'friend',
|
||||
sendNickName: 'friend',
|
||||
sendRemarkName: 'friend',
|
||||
msgTime: '1',
|
||||
recallTime: '0',
|
||||
elements: [],
|
||||
records: [],
|
||||
sourceType: 0,
|
||||
isOnlineMsg: true,
|
||||
...overrides,
|
||||
} as RawMessage;
|
||||
}
|
||||
|
||||
function createRecallGrayTip (): RawMessage {
|
||||
return createMessage({
|
||||
msgId: recalledMsgId,
|
||||
msgSeq: recalledMsgSeq,
|
||||
msgType: NTMsgType.KMSGTYPEGRAYTIPS,
|
||||
recallTime: Math.floor(Date.now() / 1000).toString(),
|
||||
elements: [{
|
||||
elementType: 8,
|
||||
elementId: 'recall-element',
|
||||
grayTipElement: {
|
||||
revokeElement: {
|
||||
operatorRole: '0',
|
||||
operatorUid: friendUid,
|
||||
operatorNick: 'friend',
|
||||
operatorRemark: '',
|
||||
isSelfOperate: false,
|
||||
wording: '',
|
||||
},
|
||||
},
|
||||
} as unknown as RawMessage['elements'][number]],
|
||||
});
|
||||
}
|
||||
|
||||
function createHarness (queryResult: RawMessage[], operatorUin = '654321') {
|
||||
let listener: TestMsgListener | undefined;
|
||||
const emitEvent = vi.fn(async (_event: unknown): Promise<void> => undefined);
|
||||
const queryMsgsWithFilterExWithSeq = vi.fn(async () => ({ msgList: queryResult }));
|
||||
const registerListen = vi.fn();
|
||||
const logger = {
|
||||
logDebug: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
logMessage: vi.fn(),
|
||||
};
|
||||
const context = {
|
||||
logger,
|
||||
session: {
|
||||
getMsgService: () => ({
|
||||
addKernelMsgListener: (registeredListener: TestMsgListener) => {
|
||||
listener = registeredListener;
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
const core = {
|
||||
selfInfo: { uin: botUin },
|
||||
apis: {
|
||||
MsgApi: { queryMsgsWithFilterExWithSeq },
|
||||
UserApi: { getUinByUidV2: vi.fn(async () => operatorUin) },
|
||||
},
|
||||
eventWrapper: { registerListen },
|
||||
};
|
||||
const adapter = Object.create(NapCatOneBot11Adapter.prototype) as NapCatOneBot11Adapter;
|
||||
|
||||
Object.assign(adapter, {
|
||||
core,
|
||||
context,
|
||||
networkManager: { emitEvent },
|
||||
bootTime: Date.now() / 1000,
|
||||
recallEventCache: new Map(),
|
||||
reportedRecallEventCache: new Map(),
|
||||
processingRecallEvents: new Map(),
|
||||
});
|
||||
|
||||
(adapter as unknown as { initMsgListener (): void }).initMsgListener();
|
||||
|
||||
if (!listener) {
|
||||
throw new Error('message listener was not registered');
|
||||
}
|
||||
|
||||
return { adapter, listener, emitEvent, logger, queryMsgsWithFilterExWithSeq, registerListen };
|
||||
}
|
||||
|
||||
describe('friend recall reporting', () => {
|
||||
test('reports a recalled non-latest private message from its gray-tip update', async () => {
|
||||
const botReply = createMessage({
|
||||
msgId: 'bot-reply-id',
|
||||
msgSeq: '101',
|
||||
senderUid: 'u_bot',
|
||||
senderUin: botUin,
|
||||
});
|
||||
const grayTip = createRecallGrayTip();
|
||||
const { listener, emitEvent } = createHarness([botReply]);
|
||||
|
||||
await listener.onMsgRecall(ChatType.KCHATTYPEC2C, friendUid, recalledMsgSeq);
|
||||
await listener.onMsgInfoListUpdate([grayTip]);
|
||||
|
||||
expect(emitEvent).toHaveBeenCalledTimes(1);
|
||||
expect(emitEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
notice_type: 'friend_recall',
|
||||
user_id: Number(friendUin),
|
||||
}));
|
||||
});
|
||||
|
||||
test('deduplicates the same recall from the gray-tip update and recall callback', async () => {
|
||||
const grayTip = createRecallGrayTip();
|
||||
const { listener, emitEvent } = createHarness([grayTip]);
|
||||
|
||||
await listener.onMsgInfoListUpdate([grayTip]);
|
||||
await listener.onMsgRecall(ChatType.KCHATTYPEC2C, friendUid, recalledMsgSeq);
|
||||
await listener.onMsgInfoListUpdate([grayTip]);
|
||||
|
||||
expect(emitEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('deduplicates concurrent update and callback processing', async () => {
|
||||
const grayTip = createRecallGrayTip();
|
||||
const { listener, emitEvent } = createHarness([grayTip]);
|
||||
let finishEmit: (() => void) | undefined;
|
||||
emitEvent.mockImplementationOnce(() => new Promise<void>((resolve) => {
|
||||
finishEmit = resolve;
|
||||
}));
|
||||
|
||||
const updateTask = listener.onMsgInfoListUpdate([grayTip]);
|
||||
await vi.waitFor(() => expect(emitEvent).toHaveBeenCalledTimes(1));
|
||||
const callbackTask = listener.onMsgRecall(ChatType.KCHATTYPEC2C, friendUid, recalledMsgSeq);
|
||||
|
||||
finishEmit?.();
|
||||
await Promise.all([updateTask, callbackTask]);
|
||||
|
||||
expect(emitEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('uses a concurrent duplicate callback to retry a failed report', async () => {
|
||||
const grayTip = createRecallGrayTip();
|
||||
const { listener, emitEvent, logger } = createHarness([grayTip]);
|
||||
let failFirstEmit: (() => void) | undefined;
|
||||
emitEvent
|
||||
.mockImplementationOnce(() => new Promise<void>((_resolve, reject) => {
|
||||
failFirstEmit = () => reject(new Error('network unavailable'));
|
||||
}))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const updateTask = listener.onMsgInfoListUpdate([grayTip]);
|
||||
await vi.waitFor(() => expect(emitEvent).toHaveBeenCalledTimes(1));
|
||||
const callbackTask = listener.onMsgRecall(ChatType.KCHATTYPEC2C, friendUid, recalledMsgSeq);
|
||||
|
||||
failFirstEmit?.();
|
||||
await Promise.all([updateTask, callbackTask]);
|
||||
|
||||
expect(emitEvent).toHaveBeenCalledTimes(2);
|
||||
expect(logger.logError).toHaveBeenCalledWith('处理消息撤回失败', expect.any(Error));
|
||||
});
|
||||
|
||||
test('reports one self recall without waiting for a duplicate callback', async () => {
|
||||
const grayTip = createRecallGrayTip();
|
||||
const revokeElement = grayTip.elements[0]?.grayTipElement?.revokeElement;
|
||||
if (!revokeElement) throw new Error('missing revoke element');
|
||||
revokeElement.isSelfOperate = true;
|
||||
|
||||
const { adapter, listener, emitEvent, registerListen } = createHarness([grayTip]);
|
||||
const originTimeout = setTimeout(() => undefined, 60_000);
|
||||
adapter.recallEventCache.set(recalledMsgId, originTimeout);
|
||||
|
||||
await listener.onMsgRecall(ChatType.KCHATTYPEC2C, friendUid, recalledMsgSeq);
|
||||
await listener.onMsgRecall(ChatType.KCHATTYPEC2C, friendUid, recalledMsgSeq);
|
||||
|
||||
expect(emitEvent).toHaveBeenCalledTimes(1);
|
||||
expect(adapter.recallEventCache.has(recalledMsgId)).toBe(false);
|
||||
expect(registerListen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('retries reporting when the first network emission fails', async () => {
|
||||
const grayTip = createRecallGrayTip();
|
||||
const { listener, emitEvent, logger } = createHarness([grayTip]);
|
||||
emitEvent
|
||||
.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
await listener.onMsgInfoListUpdate([grayTip]);
|
||||
await listener.onMsgInfoListUpdate([grayTip]);
|
||||
|
||||
expect(emitEvent).toHaveBeenCalledTimes(2);
|
||||
expect(logger.logError).toHaveBeenCalledWith('处理消息撤回失败', expect.any(Error));
|
||||
});
|
||||
|
||||
test('ignores historical recall rows replayed during startup sync', async () => {
|
||||
const grayTip = createRecallGrayTip();
|
||||
grayTip.recallTime = Math.floor(Date.now() / 1000 - 120).toString();
|
||||
const { listener, emitEvent } = createHarness([]);
|
||||
|
||||
await listener.onMsgInfoListUpdate([grayTip]);
|
||||
|
||||
expect(emitEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('ignores ordinary message updates', async () => {
|
||||
const ordinaryMessage = createMessage({ recallTime: Math.floor(Date.now() / 1000).toString() });
|
||||
const { listener, emitEvent } = createHarness([]);
|
||||
|
||||
await listener.onMsgInfoListUpdate([ordinaryMessage]);
|
||||
|
||||
expect(emitEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('group recall reporting', () => {
|
||||
test('preserves group, sender and operator fields', async () => {
|
||||
const grayTip = createRecallGrayTip();
|
||||
const groupUin = '112233';
|
||||
const senderUin = '223344';
|
||||
grayTip.chatType = ChatType.KCHATTYPEGROUP;
|
||||
grayTip.peerUid = groupUin;
|
||||
grayTip.peerUin = groupUin;
|
||||
grayTip.senderUin = senderUin;
|
||||
|
||||
const { listener, emitEvent } = createHarness([], '334455');
|
||||
|
||||
await listener.onMsgInfoListUpdate([grayTip]);
|
||||
|
||||
expect(emitEvent).toHaveBeenCalledTimes(1);
|
||||
expect(emitEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
notice_type: 'group_recall',
|
||||
group_id: Number(groupUin),
|
||||
user_id: Number(senderUin),
|
||||
operator_id: 334455,
|
||||
}));
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { TypeCompiler } from '@sinclair/typebox/compiler';
|
||||
import { Value } from '@sinclair/typebox/value';
|
||||
import { Type } from '@sinclair/typebox';
|
||||
|
||||
import { OB11MessageDataSchema, OB11MessageSchema, OB11PostSendMsgSchema } from '../snowcat-onebot/types/message';
|
||||
import { OB11MessageSchema as OB11ActionMessageSchema } from '../snowcat-onebot/action/schemas';
|
||||
import { OneBotConfigSchema, loadConfig as loadOneBotConfig, OneBotConfig } from '../snowcat-onebot/config';
|
||||
import { SnowCatConfigSchema } from '../snowcat-core/helper/config';
|
||||
|
||||
describe('SnowCat Schemas Compilation', () => {
|
||||
test('should compile OB11MessageDataSchema without duplicate id error', () => {
|
||||
expect(() => TypeCompiler.Compile(OB11MessageDataSchema)).not.toThrow();
|
||||
});
|
||||
|
||||
test('should compile OB11MessageSchema without duplicate id error', () => {
|
||||
expect(() => TypeCompiler.Compile(OB11MessageSchema)).not.toThrow();
|
||||
});
|
||||
|
||||
test('should compile OB11ActionMessageSchema without duplicate id error', () => {
|
||||
expect(() => TypeCompiler.Compile(OB11ActionMessageSchema)).not.toThrow();
|
||||
});
|
||||
|
||||
test('should compile OB11PostSendMsgSchema without error', () => {
|
||||
expect(() => TypeCompiler.Compile(OB11PostSendMsgSchema)).not.toThrow();
|
||||
});
|
||||
|
||||
test('should compile QuickAction hybrid schema without error', () => {
|
||||
const QASchema = Type.Object({
|
||||
message: OB11PostSendMsgSchema,
|
||||
quick_action: OB11ActionMessageSchema,
|
||||
});
|
||||
expect(() => TypeCompiler.Compile(QASchema)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SnowCat Schemas Validation & Coercion', () => {
|
||||
test('should coerce numeric user_id to string in OB11PostSendMsgSchema', () => {
|
||||
const payload = {
|
||||
message_type: 'private',
|
||||
user_id: 123456,
|
||||
message: [{ type: 'text', data: { text: 'hello' } }],
|
||||
};
|
||||
|
||||
let data: unknown = structuredClone(payload);
|
||||
data = Value.Parse(OB11PostSendMsgSchema, data);
|
||||
|
||||
const compiler = TypeCompiler.Compile(OB11PostSendMsgSchema);
|
||||
expect(compiler.Check(data)).toBe(true);
|
||||
expect((data as Record<string, unknown>)['user_id']).toBe('123456');
|
||||
});
|
||||
|
||||
test('should validate complex mixed messages correctly', () => {
|
||||
const payload = {
|
||||
message_type: 'group',
|
||||
group_id: '654321',
|
||||
message: 'this is a string message',
|
||||
};
|
||||
|
||||
let data: unknown = structuredClone(payload);
|
||||
data = Value.Parse(OB11PostSendMsgSchema, data);
|
||||
|
||||
const compiler = TypeCompiler.Compile(OB11PostSendMsgSchema);
|
||||
expect(compiler.Check(data)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SnowCat Configuration Loaders', () => {
|
||||
test('OneBotConfig schemas should compile', () => {
|
||||
expect(() => TypeCompiler.Compile(OneBotConfigSchema)).not.toThrow();
|
||||
});
|
||||
|
||||
test('OneBotConfig should load and apply defaults correctly', () => {
|
||||
const partialConfig: Partial<OneBotConfig> = {
|
||||
network: {
|
||||
httpServers: [{
|
||||
port: 3000,
|
||||
enable: true,
|
||||
name: 'test',
|
||||
host: '127.0.0.1',
|
||||
enableCors: true,
|
||||
enableWebsocket: false,
|
||||
messagePostFormat: 'array',
|
||||
token: '',
|
||||
debug: false,
|
||||
}],
|
||||
httpSseServers: [],
|
||||
httpClients: [],
|
||||
websocketServers: [],
|
||||
websocketClients: [],
|
||||
plugins: [],
|
||||
},
|
||||
};
|
||||
const loaded = loadOneBotConfig(partialConfig);
|
||||
expect(loaded.network.httpServers[0]?.host).toBe('127.0.0.1');
|
||||
expect(loaded.network.httpServers[0]?.enableCors).toBe(true);
|
||||
expect(loaded.musicSignUrl).toBe('');
|
||||
});
|
||||
|
||||
test('OneBotConfig websocketClients should apply verifyCertificate default', () => {
|
||||
const partialConfig: Partial<OneBotConfig> = {
|
||||
network: {
|
||||
httpServers: [],
|
||||
httpSseServers: [],
|
||||
httpClients: [],
|
||||
websocketServers: [],
|
||||
websocketClients: [{
|
||||
name: 'test-ws-client',
|
||||
enable: true,
|
||||
url: 'wss://example.com',
|
||||
messagePostFormat: 'array',
|
||||
reportSelfMessage: false,
|
||||
reconnectInterval: 5000,
|
||||
token: '',
|
||||
debug: false,
|
||||
heartInterval: 30000,
|
||||
}],
|
||||
plugins: [],
|
||||
},
|
||||
};
|
||||
const loaded = loadOneBotConfig(partialConfig);
|
||||
expect(loaded.network.websocketClients[0]?.verifyCertificate).toBe(true);
|
||||
});
|
||||
|
||||
test('OneBotConfig websocketClients should preserve explicit verifyCertificate false', () => {
|
||||
const partialConfig: Partial<OneBotConfig> = {
|
||||
network: {
|
||||
httpServers: [],
|
||||
httpSseServers: [],
|
||||
httpClients: [],
|
||||
websocketServers: [],
|
||||
websocketClients: [{
|
||||
name: 'test-ws-client',
|
||||
enable: true,
|
||||
url: 'wss://example.com',
|
||||
messagePostFormat: 'array',
|
||||
reportSelfMessage: false,
|
||||
reconnectInterval: 5000,
|
||||
token: '',
|
||||
debug: false,
|
||||
heartInterval: 30000,
|
||||
verifyCertificate: false,
|
||||
}],
|
||||
plugins: [],
|
||||
},
|
||||
};
|
||||
const loaded = loadOneBotConfig(partialConfig);
|
||||
expect(loaded.network.websocketClients[0]?.verifyCertificate).toBe(false);
|
||||
});
|
||||
|
||||
test('NapcatConfig should compile and apply defaults', () => {
|
||||
let compiled: ReturnType<typeof TypeCompiler.Compile> | undefined;
|
||||
expect(() => {
|
||||
compiled = TypeCompiler.Compile(SnowCatConfigSchema);
|
||||
}).not.toThrow();
|
||||
|
||||
let data: unknown = {};
|
||||
data = Value.Parse(SnowCatConfigSchema, data);
|
||||
|
||||
expect(compiled?.Check(data)).toBe(true);
|
||||
const resolved = data as Record<string, unknown>;
|
||||
expect(resolved['consoleLog']).toBe(true);
|
||||
expect(resolved['consoleLogLevel']).toBe('info');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import crypto from 'crypto';
|
||||
import { Sha1Stream } from 'snowcat-core/packet/utils/crypto/sha1Stream';
|
||||
|
||||
describe('Sha1Stream', () => {
|
||||
it('should compute correct SHA-1 hash for empty string', () => {
|
||||
const sha1Stream = new Sha1Stream();
|
||||
const hash = sha1Stream.final();
|
||||
const expected = crypto.createHash('sha1').update('').digest();
|
||||
expect(hash).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should compute correct SHA-1 hash for simple string', () => {
|
||||
const testData = 'Hello, World!';
|
||||
const sha1Stream = new Sha1Stream();
|
||||
sha1Stream.update(Buffer.from(testData));
|
||||
const hash = sha1Stream.final();
|
||||
const expected = crypto.createHash('sha1').update(testData).digest();
|
||||
expect(hash).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should compute correct SHA-1 hash for binary data', () => {
|
||||
const testData = Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04, 0x05]);
|
||||
const sha1Stream = new Sha1Stream();
|
||||
sha1Stream.update(testData);
|
||||
const hash = sha1Stream.final();
|
||||
const expected = crypto.createHash('sha1').update(testData).digest();
|
||||
expect(hash).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle multiple update calls', () => {
|
||||
const part1 = 'Hello';
|
||||
const part2 = ', World!';
|
||||
const sha1Stream = new Sha1Stream();
|
||||
sha1Stream.update(Buffer.from(part1));
|
||||
sha1Stream.update(Buffer.from(part2));
|
||||
const hash = sha1Stream.final();
|
||||
const expected = crypto.createHash('sha1').update(part1 + part2).digest();
|
||||
expect(hash).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle large data correctly', () => {
|
||||
const testData = crypto.randomBytes(1024 * 10); // 10KB random data
|
||||
const sha1Stream = new Sha1Stream();
|
||||
sha1Stream.update(testData);
|
||||
const hash = sha1Stream.final();
|
||||
const expected = crypto.createHash('sha1').update(testData).digest();
|
||||
expect(hash).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should produce consistent results for same input', () => {
|
||||
const testData = 'Consistent test data';
|
||||
|
||||
const sha1Stream1 = new Sha1Stream();
|
||||
sha1Stream1.update(Buffer.from(testData));
|
||||
const hash1 = sha1Stream1.final();
|
||||
|
||||
const sha1Stream2 = new Sha1Stream();
|
||||
sha1Stream2.update(Buffer.from(testData));
|
||||
const hash2 = sha1Stream2.final();
|
||||
|
||||
expect(hash1).toEqual(hash2);
|
||||
});
|
||||
|
||||
it('should handle edge case with exact block size', () => {
|
||||
// SHA-1 block size is 64 bytes
|
||||
const testData = 'a'.repeat(64);
|
||||
const sha1Stream = new Sha1Stream();
|
||||
sha1Stream.update(Buffer.from(testData));
|
||||
const hash = sha1Stream.final();
|
||||
const expected = crypto.createHash('sha1').update(testData).digest();
|
||||
expect(hash).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle random data correctly', () => {
|
||||
// Run multiple random tests (reduced from 100000 to 100 for performance)
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const randomLength = Math.floor(Math.random() * 1024);
|
||||
const randomData = crypto.randomBytes(randomLength);
|
||||
const sha1Stream = new Sha1Stream();
|
||||
sha1Stream.update(randomData);
|
||||
const hash = sha1Stream.final();
|
||||
const expectedDigest = crypto.createHash('sha1').update(randomData).digest();
|
||||
expect(hash).toEqual(expectedDigest);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".."
|
||||
},
|
||||
"include": [
|
||||
"*.ts",
|
||||
"**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { NapProtoDecodeStructType, NapProtoEncodeStructType, NapProtoMsg } from 'snowcat-protobuf';
|
||||
import { ElementType, NTVideoType } from '@/snowcat-core/types/msg';
|
||||
import { SendVideoElement } from '@/snowcat-core/types/element';
|
||||
import {
|
||||
PacketMsgPicElement,
|
||||
PacketMsgVideoElement,
|
||||
} from '@/snowcat-core/packet/message/element';
|
||||
import { PacketMsgConverter } from '@/snowcat-core/packet/message/converter';
|
||||
import { assertUploadResults } from '@/snowcat-core/packet/context/uploadResult';
|
||||
import { Elem, MsgInfo } from '@/snowcat-core/packet/transformer/proto';
|
||||
import { NTV2RichMediaReq } from '@/snowcat-core/packet/transformer/proto/oidb/common/Ntv2.RichMediaReq';
|
||||
import UploadGroupVideo from '@/snowcat-core/packet/transformer/highway/UploadGroupVideo';
|
||||
import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase';
|
||||
|
||||
function createVideoMsgInfo (): NapProtoEncodeStructType<typeof MsgInfo> {
|
||||
return {
|
||||
msgInfoBody: [
|
||||
{
|
||||
index: {
|
||||
info: {
|
||||
fileSize: 1024,
|
||||
fileHash: '00112233445566778899aabbccddeeff',
|
||||
fileSha1: '00112233445566778899aabbccddeeff00112233',
|
||||
fileName: 'clip.mp4',
|
||||
type: {
|
||||
type: 2,
|
||||
videoFormat: NTVideoType.VIDEO_FORMAT_MP4,
|
||||
},
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
time: 12,
|
||||
},
|
||||
fileUuid: 'video-uuid',
|
||||
},
|
||||
},
|
||||
{
|
||||
index: {
|
||||
info: {
|
||||
fileSize: 2048,
|
||||
fileHash: 'ffeeddccbbaa99887766554433221100',
|
||||
fileSha1: 'ffeeddccbbaa99887766554433221100ffeeddcc',
|
||||
fileName: 'thumb.jpg',
|
||||
type: {
|
||||
type: 1,
|
||||
picFormat: 0,
|
||||
},
|
||||
width: 640,
|
||||
height: 360,
|
||||
},
|
||||
fileUuid: 'thumb-uuid',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createSendVideoElement (): SendVideoElement {
|
||||
return {
|
||||
elementType: ElementType.VIDEO,
|
||||
elementId: '',
|
||||
videoElement: {
|
||||
filePath: 'clip.mp4',
|
||||
fileName: 'clip.mp4',
|
||||
videoMd5: '00112233445566778899aabbccddeeff',
|
||||
thumbMd5: 'ffeeddccbbaa99887766554433221100',
|
||||
fileTime: 12,
|
||||
fileFormat: NTVideoType.VIDEO_FORMAT_MP4,
|
||||
fileSize: '1024',
|
||||
fileWidth: 1920,
|
||||
fileHeight: 1080,
|
||||
thumbWidth: 640,
|
||||
thumbHeight: 360,
|
||||
thumbSize: 2048,
|
||||
thumbPath: new Map([[0, 'thumb.jpg']]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function decodeElem (
|
||||
elem: NapProtoEncodeStructType<typeof Elem>
|
||||
): NapProtoDecodeStructType<typeof Elem> {
|
||||
const codec = new NapProtoMsg(Elem);
|
||||
return codec.decode(codec.encode(elem));
|
||||
}
|
||||
|
||||
function createCommonVideoElem (businessType: 11 | 21): NapProtoDecodeStructType<typeof Elem> {
|
||||
return decodeElem({
|
||||
commonElem: {
|
||||
serviceType: 48,
|
||||
businessType,
|
||||
pbElem: new NapProtoMsg(MsgInfo).encode(createVideoMsgInfo()),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('packet video forwarding', () => {
|
||||
it('emits only the common rich-media element so mobile rendering is not blocked', () => {
|
||||
const video = new PacketMsgVideoElement(createSendVideoElement());
|
||||
video.msgInfo = createVideoMsgInfo();
|
||||
video.businessType = 11;
|
||||
|
||||
const elems = video.buildElement();
|
||||
|
||||
expect(elems).toHaveLength(1);
|
||||
expect(elems[0]?.videoFile).toBeUndefined();
|
||||
expect(elems[0]?.commonElem?.serviceType).toBe(48);
|
||||
expect(elems[0]?.commonElem?.businessType).toBe(11);
|
||||
expect(video.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('parses common business type 11/21 as video instead of image', () => {
|
||||
for (const businessType of [11, 21] as const) {
|
||||
const commonElem = createCommonVideoElem(businessType);
|
||||
expect(PacketMsgPicElement.parseElement(commonElem)).toBeUndefined();
|
||||
|
||||
const parsed = new PacketMsgConverter().packetMsgToRaw([commonElem]);
|
||||
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0]?.[0].elementType).toBe(ElementType.VIDEO);
|
||||
expect(parsed[0]?.[0].picElement).toBeUndefined();
|
||||
expect(parsed[0]?.[0].videoElement).toMatchObject({
|
||||
fileName: 'clip.mp4',
|
||||
fileTime: 12,
|
||||
fileWidth: 1920,
|
||||
fileHeight: 1080,
|
||||
thumbWidth: 640,
|
||||
thumbHeight: 360,
|
||||
fileUuid: 'video-uuid',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves video metadata in the rich-media upload request', () => {
|
||||
const video = new PacketMsgVideoElement(createSendVideoElement());
|
||||
video.fileSha1 = '00112233445566778899aabbccddeeff00112233';
|
||||
video.thumbSha1 = 'ffeeddccbbaa99887766554433221100ffeeddcc';
|
||||
|
||||
const packet = UploadGroupVideo.build(123456, video);
|
||||
const envelope = OidbBase.parse(packet.data);
|
||||
const request = new NapProtoMsg(NTV2RichMediaReq).decode(envelope.body);
|
||||
const videoInfo = request.upload?.uploadInfo[0]?.fileInfo;
|
||||
const thumbInfo = request.upload?.uploadInfo[1]?.fileInfo;
|
||||
|
||||
expect(videoInfo).toMatchObject({
|
||||
fileName: 'clip.mp4',
|
||||
fileSize: 1024,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
time: 12,
|
||||
});
|
||||
expect(videoInfo?.type.videoFormat).toBe(NTVideoType.VIDEO_FORMAT_MP4);
|
||||
expect(thumbInfo?.fileName).toBe('ffeeddccbbaa99887766554433221100.jpg');
|
||||
});
|
||||
|
||||
it('propagates resource upload failures instead of silently continuing', () => {
|
||||
const uploadError = new Error('thumb upload failed');
|
||||
const logger = {
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
expect(() => assertUploadResults([
|
||||
{ status: 'fulfilled', value: undefined },
|
||||
{ status: 'rejected', reason: uploadError },
|
||||
], logger)).toThrow('上传转发消息资源失败');
|
||||
expect(logger.warn).toHaveBeenCalledWith('上传资源2个,失败1个');
|
||||
expect(logger.error).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { OneBotFileApi } from '@/snowcat-onebot/api/file';
|
||||
import { FFmpegService } from '@/snowcat-core/helper/ffmpeg/ffmpeg';
|
||||
import { NTVideoType } from '@/snowcat-core/types/msg';
|
||||
|
||||
const MP4_HEADER = Buffer.from([
|
||||
0x00, 0x00, 0x00, 0x18,
|
||||
0x66, 0x74, 0x79, 0x70,
|
||||
0x69, 0x73, 0x6f, 0x6d,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x69, 0x73, 0x6f, 0x6d,
|
||||
]);
|
||||
const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const JPEG_HEADER = Buffer.from([0xff, 0xd8, 0xff, 0xe0]);
|
||||
|
||||
describe('OneBot video send modes', () => {
|
||||
let tempDir: string;
|
||||
let sourceVideoPath: string;
|
||||
let uploadedVideoPath: string;
|
||||
let fileApi: OneBotFileApi;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(path.join(tmpdir(), 'snowcat-video-send-mode-'));
|
||||
sourceVideoPath = path.join(tempDir, 'clip.mp4');
|
||||
uploadedVideoPath = path.join(tempDir, 'cache', 'Ori', 'clip.mp4');
|
||||
await mkdir(path.dirname(uploadedVideoPath), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(sourceVideoPath, MP4_HEADER),
|
||||
writeFile(uploadedVideoPath, MP4_HEADER),
|
||||
]);
|
||||
|
||||
const core = {
|
||||
SnowCatTempPath: tempDir,
|
||||
context: {
|
||||
logger: {
|
||||
logError: vi.fn(),
|
||||
},
|
||||
},
|
||||
apis: {
|
||||
FileApi: {
|
||||
uploadFile: vi.fn().mockResolvedValue({
|
||||
fileName: 'clip.mp4',
|
||||
path: uploadedVideoPath,
|
||||
fileSize: MP4_HEADER.length,
|
||||
md5: '00112233445566778899aabbccddeeff',
|
||||
}),
|
||||
copyFile: vi.fn(),
|
||||
},
|
||||
},
|
||||
} as unknown as ConstructorParameters<typeof OneBotFileApi>[1];
|
||||
|
||||
fileApi = new OneBotFileApi(
|
||||
{} as ConstructorParameters<typeof OneBotFileApi>[0],
|
||||
core
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('uses a PNG thumbnail and legacy minimal metadata for direct kernel sends', async () => {
|
||||
const getVideoInfo = vi.spyOn(FFmpegService, 'getVideoInfo').mockResolvedValue({
|
||||
width: 480,
|
||||
height: 852,
|
||||
time: 3.6,
|
||||
format: 'jpg',
|
||||
size: MP4_HEADER.length,
|
||||
filePath: sourceVideoPath,
|
||||
});
|
||||
const extractLegacyThumbnail = vi
|
||||
.spyOn(FFmpegService, 'extractLegacyVideoThumbnail')
|
||||
.mockImplementation(async (_videoPath, thumbnailPath) => {
|
||||
await writeFile(thumbnailPath, PNG_HEADER);
|
||||
});
|
||||
|
||||
const context = {
|
||||
deleteAfterSentFiles: [],
|
||||
peer: {},
|
||||
} as unknown as Parameters<OneBotFileApi['createValidSendVideoElement']>[0];
|
||||
const element = await fileApi.createValidSendVideoElement(context, sourceVideoPath);
|
||||
const thumbnailPath = element.videoElement.thumbPath?.get(0) as string;
|
||||
|
||||
expect(getVideoInfo).toHaveBeenCalledWith(sourceVideoPath, '');
|
||||
expect(extractLegacyThumbnail).toHaveBeenCalledWith(sourceVideoPath, thumbnailPath);
|
||||
expect(path.extname(thumbnailPath)).toBe('.png');
|
||||
expect((await readFile(thumbnailPath)).subarray(0, PNG_HEADER.length)).toEqual(PNG_HEADER);
|
||||
expect(element.videoElement.fileTime).toBe(3.6);
|
||||
expect('fileFormat' in element.videoElement).toBe(false);
|
||||
expect('fileWidth' in element.videoElement).toBe(false);
|
||||
expect('fileHeight' in element.videoElement).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps JPEG thumbnails and complete metadata for packet forward uploads', async () => {
|
||||
const getVideoInfo = vi
|
||||
.spyOn(FFmpegService, 'getVideoInfo')
|
||||
.mockImplementation(async (videoPath, thumbnailPath) => {
|
||||
await writeFile(thumbnailPath, JPEG_HEADER);
|
||||
return {
|
||||
width: 480,
|
||||
height: 852,
|
||||
time: 3.6,
|
||||
format: 'jpg',
|
||||
size: MP4_HEADER.length,
|
||||
filePath: videoPath,
|
||||
};
|
||||
});
|
||||
const extractLegacyThumbnail = vi.spyOn(FFmpegService, 'extractLegacyVideoThumbnail');
|
||||
|
||||
const context = {
|
||||
deleteAfterSentFiles: [],
|
||||
peer: {},
|
||||
usePacketVideoMetadata: true,
|
||||
} as unknown as Parameters<OneBotFileApi['createValidSendVideoElement']>[0];
|
||||
const element = await fileApi.createValidSendVideoElement(context, sourceVideoPath);
|
||||
const thumbnailPath = element.videoElement.thumbPath?.get(0) as string;
|
||||
|
||||
expect(getVideoInfo).toHaveBeenCalledWith(sourceVideoPath, thumbnailPath);
|
||||
expect(extractLegacyThumbnail).not.toHaveBeenCalled();
|
||||
expect(path.extname(thumbnailPath)).toBe('.jpg');
|
||||
expect((await readFile(thumbnailPath)).subarray(0, JPEG_HEADER.length)).toEqual(JPEG_HEADER);
|
||||
expect(element.videoElement.fileTime).toBe(4);
|
||||
expect(element.videoElement.fileFormat).toBe(NTVideoType.VIDEO_FORMAT_MP4);
|
||||
expect(element.videoElement.fileWidth).toBe(480);
|
||||
expect(element.videoElement.fileHeight).toBe(852);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@/snowcat-rpc': resolve(__dirname, '../snowcat-rpc'),
|
||||
'@/snowcat-image-size': resolve(__dirname, '../snowcat-image-size'),
|
||||
'@/snowcat-test': resolve(__dirname, '.'),
|
||||
'@/snowcat-common': resolve(__dirname, '../snowcat-common'),
|
||||
'@/snowcat-core': resolve(__dirname, '../snowcat-core'),
|
||||
'@/snowcat-onebot': resolve(__dirname, '../snowcat-onebot'),
|
||||
'snowcat-protobuf': resolve(__dirname, '../snowcat-protobuf/NapProto.ts'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user