SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,131 @@
|
||||
// ChunkSource is the buffer-or-disk abstraction the highway PUT loop reads
|
||||
// through (#stream-upload). The whole streaming refactor rests on one
|
||||
// invariant: a FileChunkSource must yield BYTE-FOR-BYTE the same chunks as a
|
||||
// BufferChunkSource over the same file, at every (offset, length) the uploader
|
||||
// asks for. These tests are that byte-oracle.
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { promises as fsp } from 'fs';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { BufferChunkSource, FileChunkSource } from '@snowluma/protocol/highway';
|
||||
|
||||
// Mirrors highway-client's HIGHWAY_BLOCK_SIZE chunk walk without the network:
|
||||
// step through [0,size) in `block` strides, last chunk short by construction.
|
||||
async function collectChunks(
|
||||
src: { size: number; read(o: number, l: number): Promise<Uint8Array> },
|
||||
block: number,
|
||||
) {
|
||||
const chunks: Uint8Array[] = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset < src.size) {
|
||||
const len = Math.min(block, src.size - offset);
|
||||
chunks.push(new Uint8Array(await src.read(offset, len)));
|
||||
offset += len;
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
describe('ChunkSource — FileChunkSource ≡ BufferChunkSource', () => {
|
||||
let dir: string;
|
||||
beforeAll(async () => {
|
||||
dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sl-chunk-'));
|
||||
});
|
||||
afterAll(async () => {
|
||||
await fsp.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Sizes chosen around block boundaries: exact multiple, +1, -1, tiny, empty.
|
||||
for (const [label, size, block] of [
|
||||
['empty', 0, 7],
|
||||
['tiny (< block)', 5, 7],
|
||||
['exact multiple', 21, 7],
|
||||
['one past a boundary', 22, 7],
|
||||
['one before a boundary', 20, 7],
|
||||
['single full block', 7, 7],
|
||||
] as const) {
|
||||
it(`${label}: chunks + size are byte-identical (size=${size}, block=${block})`, async () => {
|
||||
const data = new Uint8Array(size);
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
data[i] = (i * 31 + 7) & 0xff;
|
||||
}
|
||||
|
||||
const file = path.join(dir, `${randomUUID()}.bin`);
|
||||
fs.writeFileSync(file, data);
|
||||
|
||||
const buf = new BufferChunkSource(data);
|
||||
const fch = await FileChunkSource.open(file, size);
|
||||
|
||||
try {
|
||||
expect(fch.size).toBe(buf.size);
|
||||
expect(fch.size).toBe(size);
|
||||
const a = await collectChunks(buf, block);
|
||||
const b = await collectChunks(fch, block);
|
||||
expect(b.length).toBe(a.length);
|
||||
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
expect(Buffer.from(b[i]!).equals(Buffer.from(a[i]!))).toBe(true);
|
||||
}
|
||||
|
||||
// Reassembled stream equals the original bytes exactly.
|
||||
expect(Buffer.concat(b.map((c) => Buffer.from(c))).equals(Buffer.from(data))).toBe(
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
await fch.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('FileChunkSource.read returns EXACTLY the requested length (no short read leaks)', async () => {
|
||||
const data = new Uint8Array(1000).map((_, i) => (i * 13) & 0xff);
|
||||
const file = path.join(dir, `${randomUUID()}.bin`);
|
||||
fs.writeFileSync(file, data);
|
||||
const fch = await FileChunkSource.open(file, data.length);
|
||||
|
||||
try {
|
||||
for (const [off, len] of [
|
||||
[0, 1],
|
||||
[0, 1000],
|
||||
[37, 200],
|
||||
[999, 1],
|
||||
[500, 500],
|
||||
] as const) {
|
||||
const got = await fch.read(off, len);
|
||||
expect(got.length).toBe(len);
|
||||
expect(Buffer.from(got).equals(Buffer.from(data.subarray(off, off + len)))).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await fch.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('FileChunkSource.read throws on unexpected EOF (request past end)', async () => {
|
||||
const data = new Uint8Array(10);
|
||||
const file = path.join(dir, `${randomUUID()}.bin`);
|
||||
fs.writeFileSync(file, data);
|
||||
const fch = await FileChunkSource.open(file, 10);
|
||||
|
||||
try {
|
||||
await expect(fch.read(5, 20)).rejects.toThrow(/unexpected EOF/);
|
||||
} finally {
|
||||
await fch.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('BufferChunkSource.close and FileChunkSource.close both resolve (idempotent contract)', async () => {
|
||||
const buf = new BufferChunkSource(new Uint8Array(3));
|
||||
await expect(buf.close()).resolves.toBeUndefined();
|
||||
const file = path.join(dir, `${randomUUID()}.bin`);
|
||||
fs.writeFileSync(file, new Uint8Array(3));
|
||||
const fch = await FileChunkSource.open(file, 3);
|
||||
await expect(fch.close()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
// hashFileStreaming is the streaming replacement for the buffered
|
||||
// `computeHashes` + `computeVideoSha1Blocks` (+ head-md5) used before an
|
||||
// upload. The refactor's whole promise is byte-for-byte identity, so this
|
||||
// test IS the oracle: for a spread of sizes around the 1 MiB block boundary
|
||||
// (and a spread of head limits around chunk boundaries) it asserts the
|
||||
// streaming result equals the buffered functions over the same bytes.
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { promises as fsp } from 'fs';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { hashFileStreaming, hashFlashFileStreaming } from '@snowluma/protocol/highway/hash-file';
|
||||
import { computeHashes, computeMd5 } from '@snowluma/protocol/highway/utils';
|
||||
import { computeSha1StateV } from '@snowluma/protocol/highway/sha1-stream';
|
||||
import { computeVideoSha1Blocks } from '@snowluma/protocol/highway/video-upload';
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
function synth(size: number): Uint8Array {
|
||||
const b = new Uint8Array(size);
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
b[i] = (i * 131 + 7) & 0xff;
|
||||
}
|
||||
|
||||
return b;
|
||||
}
|
||||
|
||||
function eq(a: Uint8Array, b: Uint8Array): boolean {
|
||||
return Buffer.from(a).equals(Buffer.from(b));
|
||||
}
|
||||
|
||||
describe('hashFileStreaming ≡ buffered computeHashes + computeVideoSha1Blocks + head-md5', () => {
|
||||
let dir: string;
|
||||
beforeAll(async () => {
|
||||
dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sl-hash-'));
|
||||
});
|
||||
afterAll(async () => {
|
||||
await fsp.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Sizes chosen around the 1 MiB block boundary + sub-block + multi-block.
|
||||
const SIZES = [0, 1, 63, 64, 65, MB - 1, MB, MB + 1, 2 * MB, 2 * MB + 123];
|
||||
|
||||
for (const size of SIZES) {
|
||||
it(`size=${size}: md5 / sha1 / sha1Blocks match the buffered helpers`, async () => {
|
||||
const data = synth(size);
|
||||
const file = path.join(dir, `${randomUUID()}.bin`);
|
||||
fs.writeFileSync(file, data);
|
||||
|
||||
const streamed = await hashFileStreaming(file);
|
||||
const buffered = computeHashes(data);
|
||||
const refBlocks = computeVideoSha1Blocks(data);
|
||||
|
||||
expect(eq(streamed.md5, buffered.md5)).toBe(true);
|
||||
expect(eq(streamed.sha1, buffered.sha1)).toBe(true);
|
||||
expect(streamed.md5Hex).toBe(buffered.md5Hex);
|
||||
expect(streamed.sha1Hex).toBe(buffered.sha1Hex);
|
||||
expect(streamed.headMd5).toBeUndefined();
|
||||
|
||||
// sha1Blocks must match exactly — same count, same bytes, same order.
|
||||
expect(streamed.sha1Blocks.length).toBe(refBlocks.length);
|
||||
|
||||
for (let i = 0; i < refBlocks.length; i++) {
|
||||
expect(eq(streamed.sha1Blocks[i]!, refBlocks[i]!)).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('headMd5 matches computeMd5(first N bytes) across boundaries (±1, block edge, ≥size)', async () => {
|
||||
const size = 2 * MB + 500;
|
||||
const data = synth(size);
|
||||
const file = path.join(dir, `${randomUUID()}.bin`);
|
||||
fs.writeFileSync(file, data);
|
||||
|
||||
// head limits: empty, tiny, mid-first-block, exact block boundary ±1,
|
||||
// and past the end (must clamp to size).
|
||||
const limits = [0, 1, 1000, MB - 1, MB, MB + 1, size - 1, size, size + 1];
|
||||
|
||||
for (const L of limits) {
|
||||
const streamed = await hashFileStreaming(file, { headLimit: L });
|
||||
const ref = computeMd5(data.subarray(0, Math.min(data.length, L)));
|
||||
expect(streamed.headMd5).toBeDefined();
|
||||
expect(eq(streamed.headMd5!, ref)).toBe(true);
|
||||
// The rest of the digests are unaffected by headLimit.
|
||||
expect(streamed.md5Hex).toBe(computeHashes(data).md5Hex);
|
||||
}
|
||||
});
|
||||
|
||||
it('empty file: md5/sha1 of nothing, single finalized sha1 block, headMd5 of empty', async () => {
|
||||
const file = path.join(dir, `${randomUUID()}.bin`);
|
||||
fs.writeFileSync(file, new Uint8Array(0));
|
||||
const streamed = await hashFileStreaming(file, { headLimit: 10 });
|
||||
const buffered = computeHashes(new Uint8Array(0));
|
||||
expect(streamed.md5Hex).toBe(buffered.md5Hex);
|
||||
expect(streamed.sha1Hex).toBe(buffered.sha1Hex);
|
||||
expect(streamed.sha1Blocks.length).toBe(1); // just the finalized whole-file sha1
|
||||
expect(eq(streamed.sha1Blocks[0]!, buffered.sha1)).toBe(true);
|
||||
expect(eq(streamed.headMd5!, computeMd5(new Uint8Array(0)))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Flash sliceupload Sha1StateV is NOT video sha1Blocks. Both snapshot
|
||||
// un-finalized SHA1 at 1 MiB boundaries, but flash omits the un-finalized
|
||||
// snapshot of the last full slice — that slot is the finalized whole-file
|
||||
// SHA1. Reusing sha1Blocks when size % 1 MiB === 0 ships an extra state
|
||||
// and the server rejects the upload.
|
||||
describe('hashFlashFileStreaming ≡ buffered computeHashes + computeSha1StateV', () => {
|
||||
let dir: string;
|
||||
beforeAll(async () => {
|
||||
dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sl-flash-hash-'));
|
||||
});
|
||||
afterAll(async () => {
|
||||
await fsp.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const SIZES = [0, 1, 63, 64, 65, MB - 1, MB, MB + 1, 2 * MB, 2 * MB + 123];
|
||||
|
||||
for (const size of SIZES) {
|
||||
it(`size=${size}: md5 / sha1 / Sha1StateV match the buffered flash helpers`, async () => {
|
||||
const data = synth(size);
|
||||
const file = path.join(dir, `${randomUUID()}.bin`);
|
||||
fs.writeFileSync(file, data);
|
||||
|
||||
const streamed = await hashFlashFileStreaming(file);
|
||||
const buffered = computeHashes(data);
|
||||
const sliceCount = Math.ceil(size / MB);
|
||||
const refState = computeSha1StateV(data, sliceCount, MB);
|
||||
|
||||
expect(eq(streamed.md5, buffered.md5)).toBe(true);
|
||||
expect(eq(streamed.sha1, buffered.sha1)).toBe(true);
|
||||
expect(streamed.md5Hex).toBe(buffered.md5Hex);
|
||||
expect(streamed.sha1Hex).toBe(buffered.sha1Hex);
|
||||
expect(streamed.sliceCount).toBe(sliceCount);
|
||||
expect(streamed.sha1StateV.length).toBe(refState.length);
|
||||
|
||||
for (let i = 0; i < refState.length; i++) {
|
||||
expect(eq(streamed.sha1StateV[i]!, refState[i]!)).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('exact 1 MiB is not video sha1Blocks (no extra un-finalized last-slice state)', async () => {
|
||||
const data = synth(MB);
|
||||
const file = path.join(dir, `${randomUUID()}.bin`);
|
||||
fs.writeFileSync(file, data);
|
||||
|
||||
const flash = await hashFlashFileStreaming(file);
|
||||
const video = await hashFileStreaming(file);
|
||||
expect(flash.sha1StateV.length).toBe(1);
|
||||
expect(video.sha1Blocks.length).toBe(2);
|
||||
expect(eq(flash.sha1StateV[0]!, video.sha1Blocks[1]!)).toBe(true);
|
||||
expect(eq(flash.sha1StateV[0]!, video.sha1Blocks[0]!)).toBe(false);
|
||||
});
|
||||
|
||||
it('exact 2 MiB: flash has 2 states, video sha1Blocks has 3', async () => {
|
||||
const data = synth(2 * MB);
|
||||
const file = path.join(dir, `${randomUUID()}.bin`);
|
||||
fs.writeFileSync(file, data);
|
||||
|
||||
const flash = await hashFlashFileStreaming(file);
|
||||
const video = await hashFileStreaming(file);
|
||||
expect(flash.sliceCount).toBe(2);
|
||||
expect(flash.sha1StateV.length).toBe(2);
|
||||
expect(video.sha1Blocks.length).toBe(3);
|
||||
expect(eq(flash.sha1StateV[0]!, video.sha1Blocks[0]!)).toBe(true);
|
||||
expect(eq(flash.sha1StateV[1]!, video.sha1Blocks[2]!)).toBe(true);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,531 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getLogLevel, setLogLevel, subscribeLogs, type LogEntry } from '@snowluma/common/logger';
|
||||
import type { ReqDataHighwayHead, RespDataHighwayHead } from '@snowluma/proto-defs/highway';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { BridgeContext } from '@snowluma/protocol/bridge-context';
|
||||
import { packHighwayFrame, unpackHighwayFrame, computeMd5 } from '@snowluma/protocol/highway/utils';
|
||||
import {
|
||||
uploadHighwayHttp,
|
||||
type ChunkSource,
|
||||
type HighwaySession,
|
||||
} from '@snowluma/protocol/highway';
|
||||
|
||||
const { connect } = vi.hoisted(() => ({ connect: vi.fn() }));
|
||||
vi.mock('net', () => ({ default: { createConnection: connect } }));
|
||||
const BLOCK = 1024 * 1024;
|
||||
const bridge = { identity: { uin: '10001' } } as unknown as BridgeContext;
|
||||
const session: HighwaySession = {
|
||||
sigSession: new Uint8Array([1]),
|
||||
sessionKey: new Uint8Array(),
|
||||
servers: [
|
||||
{ host: '192.0.2.1', port: 80 },
|
||||
{ host: '192.0.2.2', port: 80 },
|
||||
],
|
||||
};
|
||||
const turn = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
interface Request {
|
||||
socket: TestSocket;
|
||||
head: ReqDataHighwayHead;
|
||||
bytes: Uint8Array;
|
||||
offset: number;
|
||||
respond(override?: RespDataHighwayHead): void;
|
||||
frame(bytes: Uint8Array): void;
|
||||
disconnect(): void;
|
||||
}
|
||||
let requests: Request[];
|
||||
let sockets: TestSocket[];
|
||||
let active: Set<Request>;
|
||||
let peak: number;
|
||||
let onRequest: (request: Request) => void;
|
||||
|
||||
class TestSocket extends EventEmitter {
|
||||
destroyed = false;
|
||||
readableEnded = false;
|
||||
writable = true;
|
||||
private buffer = Buffer.alloc(0);
|
||||
setTimeout() {
|
||||
return this;
|
||||
}
|
||||
write(data: Uint8Array, callback: (error?: Error) => void): boolean {
|
||||
if (this.destroyed) {
|
||||
callback(new Error('socket closed'));
|
||||
return false;
|
||||
}
|
||||
|
||||
this.buffer = Buffer.concat([this.buffer, data]);
|
||||
const end = this.buffer.indexOf('\r\n\r\n');
|
||||
|
||||
if (end >= 0) {
|
||||
const length = Number(
|
||||
this.buffer
|
||||
.subarray(0, end)
|
||||
.toString()
|
||||
.match(/Content-Length: (\d+)/i)![1],
|
||||
);
|
||||
|
||||
if (this.buffer.length >= end + 4 + length) {
|
||||
const { head, body } = unpackHighwayFrame(
|
||||
this.buffer.subarray(end + 4, end + 4 + length),
|
||||
);
|
||||
const decoded = protobuf_decode<ReqDataHighwayHead>(head);
|
||||
this.buffer = this.buffer.subarray(end + 4 + length);
|
||||
const request: Request = {
|
||||
socket: this,
|
||||
head: decoded,
|
||||
bytes: body,
|
||||
offset: Number(decoded.msgSegHead?.dataOffset ?? 0n),
|
||||
frame: (frame) => {
|
||||
if (this.destroyed) {
|
||||
throw new Error('reply on destroyed socket');
|
||||
}
|
||||
|
||||
active.delete(request);
|
||||
this.emit(
|
||||
'data',
|
||||
Buffer.concat([
|
||||
Buffer.from(
|
||||
`HTTP/1.1 200 OK\r\nContent-Length: ${frame.length}\r\n\r\n`,
|
||||
),
|
||||
frame,
|
||||
]),
|
||||
);
|
||||
},
|
||||
respond: (override = {}) =>
|
||||
request.frame(
|
||||
packHighwayFrame(
|
||||
protobuf_encode<RespDataHighwayHead>({
|
||||
msgBaseHead: { seq: decoded.msgBaseHead?.seq },
|
||||
msgSegHead: {
|
||||
filesize: decoded.msgSegHead?.filesize,
|
||||
dataOffset: decoded.msgSegHead?.dataOffset,
|
||||
dataLength: decoded.msgSegHead?.dataLength,
|
||||
},
|
||||
...override,
|
||||
}),
|
||||
new Uint8Array(),
|
||||
),
|
||||
),
|
||||
disconnect: () => this.emit('error', new Error('ECONNRESET')),
|
||||
};
|
||||
expect([...active].some((item) => item.socket === this)).toBe(false);
|
||||
requests.push(request);
|
||||
active.add(request);
|
||||
peak = Math.max(peak, active.size);
|
||||
// Deliberately respond before the write callback. Real fast peers
|
||||
// can reply while socket.write is still completing.
|
||||
onRequest(request);
|
||||
}
|
||||
}
|
||||
|
||||
callback();
|
||||
return true;
|
||||
}
|
||||
destroy() {
|
||||
if (!this.destroyed) {
|
||||
this.destroyed = true;
|
||||
this.writable = false;
|
||||
|
||||
for (const request of active) {
|
||||
if (request.socket === this) {
|
||||
active.delete(request);
|
||||
}
|
||||
}
|
||||
|
||||
queueMicrotask(() => this.emit('close'));
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
class Source implements ChunkSource {
|
||||
reads: number[] = [];
|
||||
closes = 0;
|
||||
constructor(readonly size: number) {}
|
||||
async read(offset: number, length: number) {
|
||||
this.reads.push(offset);
|
||||
return new Uint8Array(length).fill(offset / BLOCK + 1);
|
||||
}
|
||||
async close() {
|
||||
this.closes += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function upload(source: ChunkSource, concurrency: number) {
|
||||
return uploadHighwayHttp(bridge, session, 1005, source, new Uint8Array(16), new Uint8Array(), {
|
||||
concurrency,
|
||||
});
|
||||
}
|
||||
|
||||
const level = getLogLevel();
|
||||
beforeEach(() => {
|
||||
requests = [];
|
||||
sockets = [];
|
||||
active = new Set();
|
||||
peak = 0;
|
||||
|
||||
onRequest = () => {};
|
||||
|
||||
connect.mockReset().mockImplementation((_options, listener) => {
|
||||
const socket = new TestSocket();
|
||||
sockets.push(socket);
|
||||
setImmediate(listener);
|
||||
return socket;
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
setLogLevel(level);
|
||||
vi.unstubAllEnvs();
|
||||
vi.useRealTimers();
|
||||
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
describe('bounded parallel highway uploads', () => {
|
||||
it('does not let trickled response bytes extend the whole-file deadline', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubEnv('SNOWLUMA_HIGHWAY_UPLOAD_TIMEOUT_MS', '100');
|
||||
vi.stubEnv('SNOWLUMA_HIGHWAY_UPLOAD_IDLE_TIMEOUT_MS', '30');
|
||||
|
||||
onRequest = ({ socket }) => {
|
||||
const timer = setInterval(() => socket.emit('data', Buffer.from('x')), 10);
|
||||
socket.once('close', () => clearInterval(timer));
|
||||
};
|
||||
|
||||
const source = new Source(80);
|
||||
const start = Date.now();
|
||||
const failed = expect(upload(source, 1)).rejects.toThrow('timed out after 100ms');
|
||||
await Promise.all([failed, vi.runAllTimersAsync()]);
|
||||
expect(Date.now() - start).toBe(100);
|
||||
expect(source.closes).toBe(1);
|
||||
expect(active.size).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('drains a pending source read after the deadline without sending its bytes', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubEnv('SNOWLUMA_HIGHWAY_UPLOAD_TIMEOUT_MS', '50');
|
||||
const source = new Source(80);
|
||||
let reading = false;
|
||||
const read = source.read.bind(source);
|
||||
|
||||
source.read = async (offset, length) => {
|
||||
reading = true;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 100));
|
||||
reading = false;
|
||||
return read(offset, length);
|
||||
};
|
||||
|
||||
const close = vi.spyOn(source, 'close').mockImplementation(async () => {
|
||||
expect(reading).toBe(false);
|
||||
});
|
||||
const failed = expect(upload(source, 1)).rejects.toThrow('timed out after 50ms');
|
||||
await Promise.all([failed, vi.runAllTimersAsync()]);
|
||||
expect(connect).not.toHaveBeenCalled();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('aborts all parallel requests at one whole-file deadline', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubEnv('SNOWLUMA_HIGHWAY_UPLOAD_TIMEOUT_MS', '1000');
|
||||
const source = new Source(6 * BLOCK);
|
||||
const start = Date.now();
|
||||
const failed = expect(upload(source, 3)).rejects.toThrow('timed out after 1000ms');
|
||||
await Promise.all([failed, vi.runAllTimersAsync()]);
|
||||
expect(Date.now() - start).toBe(1000);
|
||||
expect(requests).toHaveLength(3);
|
||||
expect(sockets.every((socket) => socket.destroyed)).toBe(true);
|
||||
expect(active.size).toBe(0);
|
||||
expect(source.closes).toBe(1);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
it.each([2, 3, 4])(
|
||||
'uses %i connections, accepts out-of-order acknowledgements and sends the tail last',
|
||||
async (concurrency) => {
|
||||
const source = new Source(6 * BLOCK + 17);
|
||||
const confirmed = new Set<number>();
|
||||
|
||||
const checkAndAck = (request: Request) => {
|
||||
if (request.offset === 6 * BLOCK) {
|
||||
expect(confirmed.size).toBe(6);
|
||||
}
|
||||
|
||||
expect(request.bytes.every((byte) => byte === request.offset / BLOCK + 1)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(Buffer.from(request.head.msgSegHead!.md5!)).toEqual(
|
||||
Buffer.from(computeMd5(request.bytes)),
|
||||
);
|
||||
confirmed.add(request.offset);
|
||||
request.respond();
|
||||
};
|
||||
|
||||
const result = upload(source, concurrency);
|
||||
await vi.waitFor(() => expect(requests).toHaveLength(concurrency));
|
||||
expect(source.closes).toBe(0);
|
||||
onRequest = checkAndAck;
|
||||
|
||||
for (const request of [...active].reverse()) {
|
||||
checkAndAck(request);
|
||||
}
|
||||
|
||||
await result;
|
||||
expect(peak).toBe(concurrency);
|
||||
expect(sockets).toHaveLength(concurrency);
|
||||
expect(requests).toHaveLength(7);
|
||||
expect(new Set(requests.map((request) => request.head.msgBaseHead?.seq)).size).toBe(7);
|
||||
expect(source.closes).toBe(1);
|
||||
expect(sockets.every((socket) => socket.destroyed)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('logs confirmed bytes separately from the contiguous prefix after out-of-order replies', async () => {
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
const source = new Source(3 * BLOCK + 1);
|
||||
|
||||
try {
|
||||
const result = upload(source, 3);
|
||||
await vi.waitFor(() => expect(requests).toHaveLength(3));
|
||||
requests[2].respond();
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
entries.some((entry) =>
|
||||
entry.message.includes('acknowledgedBytes=1048576 contiguousBytes=0'),
|
||||
),
|
||||
).toBe(true),
|
||||
);
|
||||
expect(requests).toHaveLength(3);
|
||||
onRequest = (request) => request.respond();
|
||||
requests[1].respond();
|
||||
requests[0].respond();
|
||||
await result;
|
||||
const terminal = entries.filter((entry) =>
|
||||
entry.message.startsWith('highway_put_terminal '),
|
||||
);
|
||||
expect(terminal).toHaveLength(1);
|
||||
expect(terminal[0]?.message).toContain(`uploadedBytes=${source.size}`);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces repeated backpressure after the retry budget is exhausted', async () => {
|
||||
const source = new Source(4 * BLOCK);
|
||||
|
||||
onRequest = (request) => {
|
||||
if (request.offset === 0) {
|
||||
request.respond({ errorCode: 102902 });
|
||||
} else {
|
||||
request.respond();
|
||||
}
|
||||
};
|
||||
|
||||
await expect(upload(source, 3)).rejects.toThrow(/error_code=102902/);
|
||||
expect(requests.filter((request) => request.offset === 0)).toHaveLength(8);
|
||||
expect(requests.some((request) => request.offset === 3 * BLOCK)).toBe(false);
|
||||
expect(source.closes).toBe(1);
|
||||
});
|
||||
|
||||
it('limits lookahead behind a slow chunk and recovers an idle connection failure', async () => {
|
||||
const source = new Source(14 * BLOCK + 1);
|
||||
|
||||
onRequest = (request) => {
|
||||
if (request.offset !== 0) {
|
||||
request.respond();
|
||||
}
|
||||
};
|
||||
|
||||
const result = upload(source, 4);
|
||||
await vi.waitFor(() => expect(requests).toHaveLength(10));
|
||||
await turn();
|
||||
expect(Math.max(...requests.map((request) => request.offset))).toBe(9 * BLOCK);
|
||||
const idle = sockets.find(
|
||||
(socket) => ![...active].some((request) => request.socket === socket),
|
||||
)!;
|
||||
idle.emit('error', new Error('idle connection reset'));
|
||||
onRequest = (request) => request.respond();
|
||||
requests[0].respond();
|
||||
await result;
|
||||
expect(requests).toHaveLength(15);
|
||||
expect(source.closes).toBe(1);
|
||||
});
|
||||
|
||||
it('retries only the unconfirmed range after a transport failure', async () => {
|
||||
const source = new Source(6 * BLOCK);
|
||||
let failed = false;
|
||||
|
||||
onRequest = (request) => {
|
||||
if (request.offset === BLOCK && !failed) {
|
||||
failed = true;
|
||||
request.disconnect();
|
||||
} else {
|
||||
request.respond();
|
||||
}
|
||||
};
|
||||
|
||||
await upload(source, 3);
|
||||
const retried = requests.filter((request) => request.offset === BLOCK);
|
||||
expect(retried).toHaveLength(2);
|
||||
expect(retried[1].bytes).toEqual(retried[0].bytes);
|
||||
expect(retried[1].head.msgBaseHead?.retryTimes).toBe(1);
|
||||
expect(retried[1].head.msgBaseHead?.seq).not.toBe(retried[0].head.msgBaseHead?.seq);
|
||||
expect(source.reads).toHaveLength(6);
|
||||
expect(requests.filter((request) => request.offset !== BLOCK)).toHaveLength(5);
|
||||
expect(source.closes).toBe(1);
|
||||
});
|
||||
|
||||
it.each([102902, 302902])(
|
||||
'drains in-flight requests and reduces concurrency on %i',
|
||||
async (code) => {
|
||||
const source = new Source(7 * BLOCK);
|
||||
const entries: LogEntry[] = [];
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
const result = upload(source, 4);
|
||||
await vi.waitFor(() => expect(requests).toHaveLength(4));
|
||||
requests[0].respond({ errorCode: code });
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
entries.some((entry) =>
|
||||
entry.message.includes('reducing concurrency from 4 to 1'),
|
||||
),
|
||||
).toBe(true),
|
||||
);
|
||||
await turn();
|
||||
expect(requests).toHaveLength(4);
|
||||
let afterDrainPeak = 0;
|
||||
|
||||
onRequest = (request) => {
|
||||
afterDrainPeak = Math.max(afterDrainPeak, active.size);
|
||||
request.respond();
|
||||
};
|
||||
|
||||
for (const request of [...active]) {
|
||||
request.respond();
|
||||
}
|
||||
|
||||
await result;
|
||||
expect(afterDrainPeak).toBe(1);
|
||||
expect(requests.filter((request) => request.offset === 0)).toHaveLength(2);
|
||||
expect(requests).toHaveLength(8);
|
||||
expect(source.closes).toBe(1);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ msgBaseHead: { seq: 999 } },
|
||||
{ msgSegHead: { dataOffset: 99n } },
|
||||
{ msgSegHead: { dataLength: 99 } },
|
||||
{ msgSegHead: { filesize: 99n } },
|
||||
])('rejects mismatched acknowledgements without retrying (case %#)', async (response) => {
|
||||
const source = new Source(4 * BLOCK);
|
||||
onRequest = (request) => request.respond(response);
|
||||
await expect(upload(source, 3)).rejects.toThrow(/acknowledgement mismatch/);
|
||||
expect(requests.every((request) => (request.head.msgBaseHead?.retryTimes ?? 0) === 0)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(requests.some((request) => request.offset === 3 * BLOCK)).toBe(false);
|
||||
expect(source.closes).toBe(1);
|
||||
expect(sockets.every((socket) => socket.destroyed)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects segment-level errors even when the top-level code is zero', async () => {
|
||||
const source = new Source(4 * BLOCK);
|
||||
onRequest = (request) => request.respond({ msgSegHead: { retCode: 921 } });
|
||||
await expect(upload(source, 3)).rejects.toThrow(/segRetCode=921/);
|
||||
expect(source.closes).toBe(1);
|
||||
});
|
||||
|
||||
it('stops peers on rejection and waits for pending source reads before closing', async () => {
|
||||
let release!: () => void;
|
||||
let reading = false;
|
||||
const source = new Source(5 * BLOCK);
|
||||
const read = source.read.bind(source);
|
||||
|
||||
source.read = async (offset, length) => {
|
||||
if (offset === BLOCK) {
|
||||
reading = true;
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
reading = false;
|
||||
}
|
||||
|
||||
return read(offset, length);
|
||||
};
|
||||
|
||||
const close = vi.spyOn(source, 'close').mockImplementation(async () => {
|
||||
expect(reading).toBe(false);
|
||||
});
|
||||
const result = upload(source, 3);
|
||||
const rejected = expect(result).rejects.toThrow(/error_code=921/);
|
||||
await vi.waitFor(() => expect(requests).toHaveLength(2));
|
||||
requests.find((request) => request.offset === 0)!.respond({ errorCode: 921 });
|
||||
await turn();
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
release();
|
||||
await rejected;
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(sockets.every((socket) => socket.destroyed)).toBe(true);
|
||||
});
|
||||
|
||||
it('stops after bounded transport retries without sending the final chunk', async () => {
|
||||
const source = new Source(5 * BLOCK);
|
||||
|
||||
onRequest = (request) => {
|
||||
if (request.offset === 0) {
|
||||
request.disconnect();
|
||||
} else {
|
||||
request.respond();
|
||||
}
|
||||
};
|
||||
|
||||
await expect(upload(source, 3)).rejects.toThrow(/transport failed after 3 attempts/);
|
||||
expect(requests.filter((request) => request.offset === 0)).toHaveLength(3);
|
||||
expect(requests.some((request) => request.offset === 4 * BLOCK)).toBe(false);
|
||||
expect(source.closes).toBe(1);
|
||||
});
|
||||
|
||||
it.each([1, BLOCK + 1, 2 * BLOCK])('keeps a %i-byte upload on one connection', async (size) => {
|
||||
onRequest = (request) => request.respond();
|
||||
await upload(new Source(size), 4);
|
||||
expect(peak).toBe(1);
|
||||
expect(sockets).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps legacy success responses without echo fields compatible', async () => {
|
||||
onRequest = (request) => request.respond({ msgBaseHead: undefined, msgSegHead: undefined });
|
||||
await upload(new Source(4 * BLOCK), 3);
|
||||
expect(requests).toHaveLength(4);
|
||||
});
|
||||
|
||||
it.each([0, 5, -1, 1.5, NaN])(
|
||||
'rejects invalid concurrency %s and closes the source',
|
||||
async (concurrency) => {
|
||||
const source = new Source(4 * BLOCK);
|
||||
await expect(upload(source, concurrency)).rejects.toThrow(/integer from 1 to 4/);
|
||||
expect(source.closes).toBe(1);
|
||||
expect(requests).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects short reads instead of acknowledging bytes that were not uploaded', async () => {
|
||||
const source = new Source(4 * BLOCK);
|
||||
source.read = async () => new Uint8Array(1);
|
||||
await expect(upload(source, 3)).rejects.toThrow(/expected 1048576/);
|
||||
expect(requests).toHaveLength(0);
|
||||
expect(source.closes).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
// image-upload tests: with the pipeline mocked out, these check the
|
||||
// shape of the params each call passes — oidbCmd / serviceCmd / pic
|
||||
// extBizInfo, the Highway cmdId mapping for group vs c2c, and the
|
||||
// fast-upload (fingerprint) path.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('@snowluma/protocol/highway/pipeline', () => ({
|
||||
runNtv2Upload: vi.fn(async () => ({
|
||||
msgInfo: { msgInfoBody: [{ index: { fileUuid: 'uploaded-image' } }], extBizInfo: {} },
|
||||
})),
|
||||
finalizeMediaMsgInfo: vi.fn(() => new Uint8Array([0xca, 0xfe])),
|
||||
hexToBytes: vi.fn((hex: string) => new Uint8Array(hex.length / 2)),
|
||||
}));
|
||||
|
||||
vi.mock('@snowluma/protocol/highway/utils', () => ({
|
||||
loadBinarySource: vi.fn(async () => ({
|
||||
bytes: new Uint8Array([1, 2, 3]),
|
||||
fileName: 'src.jpg',
|
||||
})),
|
||||
computeHashes: vi.fn(() => ({
|
||||
md5: new Uint8Array(16),
|
||||
sha1: new Uint8Array(20),
|
||||
md5Hex: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
sha1Hex: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
})),
|
||||
detectImageFormat: vi.fn(() => ({ format: 1000, width: 800, height: 600 })),
|
||||
}));
|
||||
|
||||
import * as pipeline from '@snowluma/protocol/highway/pipeline';
|
||||
import * as utils from '@snowluma/protocol/highway/utils';
|
||||
import { uploadImageMsgInfo } from '@snowluma/protocol/highway/image-upload';
|
||||
import { GROUP_IMAGE_CMD_ID, PRIVATE_IMAGE_CMD_ID } from '@snowluma/protocol/highway';
|
||||
|
||||
describe('image-upload', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(pipeline.runNtv2Upload).mockClear();
|
||||
vi.mocked(pipeline.finalizeMediaMsgInfo).mockClear();
|
||||
vi.mocked(utils.loadBinarySource).mockClear();
|
||||
});
|
||||
|
||||
it('group: uses 0x11C4_100 + GROUP_IMAGE_CMD_ID and tags scene as group', async () => {
|
||||
const out = await uploadImageMsgInfo({} as any, true, 12345, { url: 'http://x' } as any);
|
||||
expect(out).toEqual(new Uint8Array([0xca, 0xfe]));
|
||||
|
||||
const args = vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0];
|
||||
expect(args.oidbCmd).toBe(0x11c4);
|
||||
expect(args.serviceCmd).toBe('OidbSvcTrpcTcp.0x11c4_100');
|
||||
expect(args.requestId).toBe(1);
|
||||
expect(args.businessType).toBe(1);
|
||||
expect(args.compatQmsgSceneType).toBe(2);
|
||||
expect(args.uploads[0]!.cmdId).toBe(GROUP_IMAGE_CMD_ID);
|
||||
expect(args.extBizInfo.pic?.extData).toBeDefined();
|
||||
expect(args.extBizInfo.pic?.bytesPbReserveC2c).toBeUndefined();
|
||||
});
|
||||
|
||||
it('c2c: uses 0x11C5_100 + PRIVATE_IMAGE_CMD_ID and tags scene as c2c', async () => {
|
||||
await uploadImageMsgInfo({} as any, false, 'recipient-uid', { url: 'http://x' } as any);
|
||||
const args = vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0];
|
||||
expect(args.oidbCmd).toBe(0x11c5);
|
||||
expect(args.serviceCmd).toBe('OidbSvcTrpcTcp.0x11c5_100');
|
||||
expect(args.compatQmsgSceneType).toBe(1);
|
||||
expect(args.uploads[0]!.cmdId).toBe(PRIVATE_IMAGE_CMD_ID);
|
||||
expect(args.extBizInfo.pic?.bytesPbReserveC2c).toBeDefined();
|
||||
expect(args.extBizInfo.pic?.extData).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses receive-side imageUrl when url/fileId are absent', async () => {
|
||||
await uploadImageMsgInfo({} as any, true, 12345, {
|
||||
type: 'image',
|
||||
imageUrl: 'https://gchat.qpic.cn/received.jpg',
|
||||
});
|
||||
expect(utils.loadBinarySource).toHaveBeenCalledWith(
|
||||
'https://gchat.qpic.cn/received.jpg',
|
||||
'image',
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes the detected picFormat and image dimensions into fileInfo', async () => {
|
||||
await uploadImageMsgInfo({} as any, true, 12345, { url: 'http://x' } as any);
|
||||
const fileInfo = (vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0].uploadInfo[0] as any)
|
||||
.fileInfo;
|
||||
expect(fileInfo.type.picFormat).toBe(1000);
|
||||
expect(fileInfo.width).toBe(800);
|
||||
expect(fileInfo.height).toBe(600);
|
||||
expect(fileInfo.original).toBe(1);
|
||||
});
|
||||
|
||||
it('forwards subType=1 (sticker) into bizType + Chinese textSummary `[动画表情]`', async () => {
|
||||
// QQ chat-list bubble preview text — must match the literal
|
||||
// strings mobile QQ / Lagrange.Core / NapCat all emit.
|
||||
await uploadImageMsgInfo({} as any, true, 12345, { url: 'http://x', subType: 1 } as any);
|
||||
const args = vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0];
|
||||
expect((args.extBizInfo as any).pic.bizType).toBe(1);
|
||||
expect((args.extBizInfo as any).pic.textSummary).toBe('[动画表情]');
|
||||
});
|
||||
|
||||
it('fingerprint path (noByteFallback): zero bytes, fastOnly error message ready', async () => {
|
||||
await uploadImageMsgInfo({} as any, true, 12345, {
|
||||
noByteFallback: true,
|
||||
md5Hex: 'aa',
|
||||
sha1Hex: 'bb',
|
||||
summary: '[图片]',
|
||||
} as any);
|
||||
const sub = vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0].uploads[0]!;
|
||||
expect(sub.bytes.length).toBe(0);
|
||||
expect(sub.fastOnlyError).toMatch(/fast-upload not available/);
|
||||
});
|
||||
|
||||
it('fingerprint path rejects when md5Hex or sha1Hex is missing', async () => {
|
||||
await expect(
|
||||
uploadImageMsgInfo({} as any, true, 12345, {
|
||||
noByteFallback: true,
|
||||
} as any),
|
||||
).rejects.toThrow(/requires md5Hex/);
|
||||
});
|
||||
|
||||
it('finalize is invoked with pic defaults for image (Chinese `[图片]`)', async () => {
|
||||
await uploadImageMsgInfo({} as any, true, 12345, { url: 'http://x', subType: 0 } as any);
|
||||
expect(pipeline.finalizeMediaMsgInfo).toHaveBeenCalledOnce();
|
||||
const [, defaultPic] = vi.mocked(pipeline.finalizeMediaMsgInfo).mock.calls[0]!;
|
||||
expect(defaultPic).toEqual({
|
||||
bizType: 0,
|
||||
textSummary: '[图片]',
|
||||
extData: { subType: 0, textSummary: '[图片]' },
|
||||
});
|
||||
});
|
||||
|
||||
it('caller-supplied summary wins over the Chinese default', async () => {
|
||||
// OneBot clients can pass `summary: '[心形图片]'` (or any custom
|
||||
// text) — that overrides our default and goes through verbatim.
|
||||
await uploadImageMsgInfo({} as any, true, 12345, {
|
||||
url: 'http://x',
|
||||
subType: 0,
|
||||
summary: '[custom-bubble]',
|
||||
} as any);
|
||||
const [, defaultPic] = vi.mocked(pipeline.finalizeMediaMsgInfo).mock.calls[0]!;
|
||||
expect(defaultPic).toEqual({
|
||||
bizType: 0,
|
||||
textSummary: '[custom-bubble]',
|
||||
extData: { subType: 0, textSummary: '[custom-bubble]' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
// Regression test for the "two images in one message" bug user report.
|
||||
//
|
||||
// User saw: 1st image fast-uploaded (server already had the bytes), 2nd
|
||||
// image triggered an actual highway PUT and the highway server returned
|
||||
// `error_code=921`.
|
||||
//
|
||||
// This test walks two `runNtv2Upload` calls back-to-back through the
|
||||
// real pipeline + buildHighwayExtend code path with the highway TCP/HTTP
|
||||
// transport stubbed, and verifies:
|
||||
//
|
||||
// - the two calls don't share state (each has its own session, its
|
||||
// own OIDB request, its own extend bytes),
|
||||
// - the second call's extend payload is well-formed (uKey + msgInfoBody
|
||||
// + ipv4s come from the *second* call's response, not the first),
|
||||
// - the highway PUT receives the second image's bytes + md5 + sha1
|
||||
// verbatim (no aliasing / truncation across calls).
|
||||
//
|
||||
// If this test ever starts failing, it's a real bug in the pipeline
|
||||
// (state sharing, async race, or wrong-buffer aliasing). If it
|
||||
// continues to pass, the user's 921 is almost certainly server-side
|
||||
// (rate limit, malformed image rejection, expired session) rather than
|
||||
// a client-emit issue.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
|
||||
vi.mock('@snowluma/protocol/highway', async () => {
|
||||
const real = await vi.importActual<typeof import('@snowluma/protocol/highway')>(
|
||||
'@snowluma/protocol/highway',
|
||||
);
|
||||
return {
|
||||
...real,
|
||||
// Only the TCP/HTTP transport is mocked — we keep real
|
||||
// `buildHighwayExtend` so we can inspect its output bytes.
|
||||
fetchHighwaySession: vi.fn(async () => ({
|
||||
sigSession: new Uint8Array([0xaa]),
|
||||
sessionKey: new Uint8Array([0xbb]),
|
||||
servers: [{ host: 'highway.example', port: 80 }],
|
||||
})),
|
||||
uploadHighwayHttp: vi.fn(async () => undefined),
|
||||
};
|
||||
});
|
||||
|
||||
import * as highway from '@snowluma/protocol/highway';
|
||||
import { runNtv2Upload, type MediaSubFileUpload } from '@snowluma/protocol/highway/pipeline';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type {
|
||||
NTV2UploadRichMediaReq,
|
||||
NTV2UploadRichMediaResp,
|
||||
NTV2RichMediaHighwayExt,
|
||||
} from '@snowluma/proto-defs/highway';
|
||||
|
||||
function encodeOidbResponse(body: any): Buffer {
|
||||
return Buffer.from(
|
||||
protobuf_encode<OidbBase<NTV2UploadRichMediaResp>>({
|
||||
command: 0x11c4,
|
||||
subCommand: 100,
|
||||
errorCode: 0,
|
||||
body: body as Record<string, unknown>,
|
||||
errorMsg: '',
|
||||
reserved: 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function makeBridge(responses: Buffer[]) {
|
||||
const sendRawPacket = vi.fn();
|
||||
|
||||
for (const r of responses) {
|
||||
sendRawPacket.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: r,
|
||||
});
|
||||
}
|
||||
|
||||
return { sendRawPacket, identity: { uin: '10001' } } as any;
|
||||
}
|
||||
|
||||
const baseParams = (bridge: any) => ({
|
||||
bridge,
|
||||
isGroup: true,
|
||||
targetIdOrUid: 894750076,
|
||||
oidbCmd: 0x11c4,
|
||||
serviceCmd: 'OidbSvcTrpcTcp.0x11c4_100',
|
||||
requestId: 1,
|
||||
businessType: 1,
|
||||
compatQmsgSceneType: 2,
|
||||
extBizInfo: { pic: { bizType: 0, textSummary: '[image]' } },
|
||||
});
|
||||
|
||||
describe('runNtv2Upload — two images in one message (sequential)', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(highway.fetchHighwaySession).mockClear();
|
||||
vi.mocked(highway.uploadHighwayHttp).mockClear();
|
||||
});
|
||||
|
||||
it("does not leak the first image's upload metadata into the second's extend", async () => {
|
||||
// Image 1 server response: NO uKey (fast-upload hit). The pipeline
|
||||
// must skip the highway PUT entirely for this call.
|
||||
// Image 2 server response: uKey + ipv4s + msgInfoBody set → real PUT.
|
||||
const image1Bytes = Buffer.from(new Uint8Array(238963));
|
||||
const image1Md5 = new Uint8Array(16).fill(0xf4);
|
||||
const image2Bytes = Buffer.from(new Uint8Array(58));
|
||||
const image2Md5 = new Uint8Array(16).fill(0xdd);
|
||||
const image2Sha1 = new Uint8Array(20).fill(0x11);
|
||||
|
||||
const bridge = makeBridge([
|
||||
// First OIDB upload response: fast-path hit (no uKey).
|
||||
encodeOidbResponse({
|
||||
upload: {
|
||||
msgInfo: {
|
||||
msgInfoBody: [{ index: { fileUuid: 'uuid-1' } }],
|
||||
extBizInfo: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
// Second OIDB upload response: needs highway PUT.
|
||||
encodeOidbResponse({
|
||||
upload: {
|
||||
uKey: 'ukey-image-2',
|
||||
ipv4s: [{ outIp: 0x04030201, outPort: 8080 }],
|
||||
msgInfo: {
|
||||
msgInfoBody: [
|
||||
{
|
||||
index: {
|
||||
fileUuid: 'uuid-2',
|
||||
info: { fileHash: 'dd', fileSha1: '11' },
|
||||
},
|
||||
},
|
||||
],
|
||||
extBizInfo: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const uploads1: MediaSubFileUpload[] = [
|
||||
{
|
||||
source: 'top',
|
||||
cmdId: 1004,
|
||||
bytes: image1Bytes,
|
||||
md5: image1Md5,
|
||||
sha1: new Uint8Array(20).fill(0xab),
|
||||
fastOnlyError: 'image fast-upload not available',
|
||||
},
|
||||
];
|
||||
const uploads2: MediaSubFileUpload[] = [
|
||||
{
|
||||
source: 'top',
|
||||
cmdId: 1004,
|
||||
bytes: image2Bytes,
|
||||
md5: image2Md5,
|
||||
sha1: image2Sha1,
|
||||
fastOnlyError: 'image fast-upload not available',
|
||||
},
|
||||
];
|
||||
|
||||
// Image 1 — fast path; must NOT touch the highway transport.
|
||||
await runNtv2Upload({
|
||||
...baseParams(bridge),
|
||||
uploadInfo: [
|
||||
{
|
||||
fileInfo: { fileName: 'a.jpg', fileSize: 238963 },
|
||||
subFileType: 0,
|
||||
},
|
||||
],
|
||||
uploads: uploads1,
|
||||
label: 'image',
|
||||
});
|
||||
expect(highway.fetchHighwaySession).not.toHaveBeenCalled();
|
||||
expect(highway.uploadHighwayHttp).not.toHaveBeenCalled();
|
||||
|
||||
// Image 2 — must PUT through highway exactly once.
|
||||
await runNtv2Upload({
|
||||
...baseParams(bridge),
|
||||
uploadInfo: [
|
||||
{
|
||||
fileInfo: { fileName: 'b.jpg', fileSize: 58 },
|
||||
subFileType: 0,
|
||||
},
|
||||
],
|
||||
uploads: uploads2,
|
||||
label: 'image',
|
||||
});
|
||||
expect(highway.fetchHighwaySession).toHaveBeenCalledOnce();
|
||||
expect(highway.uploadHighwayHttp).toHaveBeenCalledOnce();
|
||||
|
||||
// The PUT receives image-2's bytes, image-2's md5, and the
|
||||
// image-2-derived extend (NOT image-1's data).
|
||||
const [, , cmdId, putSource, putMd5, putExtend] = vi.mocked(highway.uploadHighwayHttp).mock
|
||||
.calls[0]!;
|
||||
expect(cmdId).toBe(1004);
|
||||
// The PUT source is a ChunkSource wrapping image-2's bytes — verify it
|
||||
// yields image-2 exactly (not image-1) through its public read().
|
||||
expect(putSource.size).toBe(image2Bytes.length);
|
||||
expect(
|
||||
Buffer.from(await putSource.read(0, putSource.size)).equals(Buffer.from(image2Bytes)),
|
||||
).toBe(true);
|
||||
expect(putMd5).toBe(image2Md5);
|
||||
|
||||
// Decode the extend bytes; uKey + fileUuid must come from image-2's
|
||||
// response, ipv4s must come from image-2's response.
|
||||
const extend = protobuf_decode<NTV2RichMediaHighwayExt>(putExtend);
|
||||
expect(extend.uKey).toBe('ukey-image-2');
|
||||
expect(extend.fileUuid).toBe('uuid-2');
|
||||
expect(extend.msgInfoBody?.[0]?.index?.fileUuid).toBe('uuid-2');
|
||||
expect(extend.network?.ipv4s?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('image 2 fetches its own fresh session (no leftover from a prior call)', async () => {
|
||||
// Both images hit the highway PUT path. The pipeline must call
|
||||
// fetchHighwaySession once PER runNtv2Upload (not share session
|
||||
// across calls), matching NapCat's per-context lifetime semantics
|
||||
// would actually be a future optimisation, but for now correctness
|
||||
// = "session is non-null when PUT runs".
|
||||
const ok = encodeOidbResponse({
|
||||
upload: {
|
||||
uKey: 'ukey',
|
||||
ipv4s: [{ outIp: 1, outPort: 80 }],
|
||||
msgInfo: { msgInfoBody: [{ index: { fileUuid: 'u' } }], extBizInfo: {} },
|
||||
},
|
||||
});
|
||||
const bridge = makeBridge([ok, ok]);
|
||||
|
||||
const uploads: MediaSubFileUpload[] = [
|
||||
{
|
||||
source: 'top',
|
||||
cmdId: 1004,
|
||||
bytes: Buffer.from([1, 2, 3]),
|
||||
md5: new Uint8Array(16),
|
||||
sha1: new Uint8Array(20),
|
||||
fastOnlyError: 'image fast-upload not available',
|
||||
},
|
||||
];
|
||||
|
||||
await runNtv2Upload({
|
||||
...baseParams(bridge),
|
||||
uploadInfo: [{ fileInfo: { fileName: 'a.jpg' }, subFileType: 0 }],
|
||||
uploads,
|
||||
label: 'image',
|
||||
});
|
||||
await runNtv2Upload({
|
||||
...baseParams(bridge),
|
||||
uploadInfo: [{ fileInfo: { fileName: 'b.jpg' }, subFileType: 0 }],
|
||||
uploads,
|
||||
label: 'image',
|
||||
});
|
||||
|
||||
expect(highway.fetchHighwaySession).toHaveBeenCalledTimes(2);
|
||||
expect(highway.uploadHighwayHttp).toHaveBeenCalledTimes(2);
|
||||
|
||||
// The session object passed to the PUT must be non-null both times.
|
||||
for (const call of vi.mocked(highway.uploadHighwayHttp).mock.calls) {
|
||||
const session = call[1];
|
||||
expect(session).toBeDefined();
|
||||
expect((session as any).sigSession).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("image 2's OIDB request body carries image-2's fileInfo (not image-1's)", async () => {
|
||||
// Catches a hypothetical bug where someone mutates a shared body
|
||||
// object across calls. Today the pipeline always builds a fresh
|
||||
// body, but the regression is cheap to guard.
|
||||
const ok = encodeOidbResponse({
|
||||
upload: { msgInfo: { msgInfoBody: [], extBizInfo: {} } },
|
||||
});
|
||||
const bridge = makeBridge([ok, ok]);
|
||||
|
||||
await runNtv2Upload({
|
||||
...baseParams(bridge),
|
||||
uploadInfo: [{ fileInfo: { fileName: 'first.jpg', fileSize: 100 }, subFileType: 0 }],
|
||||
uploads: [],
|
||||
label: 'image',
|
||||
});
|
||||
await runNtv2Upload({
|
||||
...baseParams(bridge),
|
||||
uploadInfo: [{ fileInfo: { fileName: 'second.jpg', fileSize: 200 }, subFileType: 0 }],
|
||||
uploads: [],
|
||||
label: 'image',
|
||||
});
|
||||
|
||||
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(2);
|
||||
const body1 = protobuf_decode<OidbBase<NTV2UploadRichMediaReq>>(
|
||||
bridge.sendRawPacket.mock.calls[0]![1],
|
||||
);
|
||||
const body2 = protobuf_decode<OidbBase<NTV2UploadRichMediaReq>>(
|
||||
bridge.sendRawPacket.mock.calls[1]![1],
|
||||
);
|
||||
expect(body1.body?.upload?.uploadInfo?.[0]?.fileInfo?.fileName).toBe('first.jpg');
|
||||
expect(body2.body?.upload?.uploadInfo?.[0]?.fileInfo?.fileName).toBe('second.jpg');
|
||||
|
||||
// And the clientRandomId is different per call (sanity).
|
||||
expect(body1.body?.upload?.clientRandomId).not.toBe(body2.body?.upload?.clientRandomId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,652 @@
|
||||
// Pipeline tests: runNtv2Upload + finalizeMediaMsgInfo.
|
||||
//
|
||||
// Highway transport (fetchHighwaySession / uploadHighwayHttp /
|
||||
// buildHighwayExtend) is mocked so tests run without a real session.
|
||||
// The OIDB response is built with real protobuf encoding so we exercise
|
||||
// the actual decode path inside the pipeline.
|
||||
|
||||
import { getLogLevel, setLogLevel, subscribeLogs, type LogEntry } from '@snowluma/common/logger';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@snowluma/protocol/highway', () => ({
|
||||
fetchHighwaySession: vi.fn(async () => ({ sessionId: 'fake-session' })),
|
||||
uploadHighwayHttp: vi.fn(async () => undefined),
|
||||
buildHighwayExtend: vi.fn(() => new Uint8Array([0xaa, 0xbb])),
|
||||
BufferChunkSource: class BufferChunkSource {
|
||||
constructor(readonly bytes: Uint8Array) {}
|
||||
},
|
||||
PRIVATE_IMAGE_CMD_ID: 1003,
|
||||
GROUP_IMAGE_CMD_ID: 1004,
|
||||
}));
|
||||
|
||||
import type {
|
||||
EncodableMediaMsgInfo,
|
||||
NTV2UploadRichMediaReq,
|
||||
NTV2UploadRichMediaResp,
|
||||
} from '@snowluma/proto-defs/highway';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import * as highway from '@snowluma/protocol/highway';
|
||||
import {
|
||||
finalizeMediaMsgInfo,
|
||||
hexToBytes,
|
||||
makeClientRandomId,
|
||||
runNtv2Upload,
|
||||
type MediaSubFileUpload,
|
||||
} from '@snowluma/protocol/highway/pipeline';
|
||||
|
||||
const previousLogLevel = getLogLevel();
|
||||
|
||||
function highwayTrace(entries: LogEntry[]): LogEntry[] {
|
||||
return entries.filter((entry) => entry.level === 'trace' && entry.scope === 'Highway');
|
||||
}
|
||||
|
||||
interface FakeUploadResponse {
|
||||
upload?: {
|
||||
uKey?: string;
|
||||
ipv4s?: Array<{ ipv4?: string; port?: number }>;
|
||||
msgInfo?: {
|
||||
msgInfoBody?: Array<Record<string, unknown>>;
|
||||
extBizInfo?: Record<string, unknown>;
|
||||
};
|
||||
subFileInfos?: Array<{
|
||||
uKey?: string;
|
||||
ipv4s?: Array<{ ipv4?: string; port?: number }>;
|
||||
}>;
|
||||
};
|
||||
respHead?: { retCode?: number; message?: string };
|
||||
}
|
||||
|
||||
function encodeOidbResponse(
|
||||
body: FakeUploadResponse,
|
||||
opts: { errorCode?: number; errorMsg?: string } = {},
|
||||
): Buffer {
|
||||
return Buffer.from(
|
||||
protobuf_encode<OidbBase<NTV2UploadRichMediaResp>>({
|
||||
command: 0x11c4,
|
||||
subCommand: 100,
|
||||
errorCode: opts.errorCode ?? 0,
|
||||
// The OIDB base schema types `body` as a generic ProtoDecoded record;
|
||||
// the test shape is more specific, so we cast through unknown.
|
||||
body: body as unknown as Record<string, unknown>,
|
||||
errorMsg: opts.errorMsg ?? '',
|
||||
reserved: 1,
|
||||
} as OidbBase<NTV2UploadRichMediaResp>),
|
||||
);
|
||||
}
|
||||
|
||||
type SendPacketResult = {
|
||||
success: boolean;
|
||||
gotResponse: boolean;
|
||||
errorCode: number;
|
||||
errorMessage: string;
|
||||
responseData: Buffer | null;
|
||||
};
|
||||
|
||||
function makeBridge(
|
||||
opts: {
|
||||
responseData?: Buffer | null;
|
||||
success?: boolean;
|
||||
errorMessage?: string;
|
||||
} = {},
|
||||
) {
|
||||
// Explicit two-arg signature so `mock.calls[0]` typechecks as
|
||||
// [string, Uint8Array] rather than the inferred empty tuple.
|
||||
const sendRawPacket = vi.fn<(cmd: string, body: Uint8Array) => Promise<SendPacketResult>>(
|
||||
async () => ({
|
||||
success: opts.success ?? true,
|
||||
gotResponse: opts.responseData !== null,
|
||||
errorCode: 0,
|
||||
errorMessage: opts.errorMessage ?? '',
|
||||
responseData: opts.responseData ?? Buffer.alloc(0),
|
||||
}),
|
||||
);
|
||||
return { sendRawPacket };
|
||||
}
|
||||
|
||||
function baseParams(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
bridge: makeBridge() as any,
|
||||
isGroup: true,
|
||||
targetIdOrUid: 12345,
|
||||
oidbCmd: 0x11c4,
|
||||
serviceCmd: 'OidbSvcTrpcTcp.0x11c4_100',
|
||||
requestId: 1,
|
||||
businessType: 1,
|
||||
uploadInfo: [{ fileInfo: { fileName: 'a.jpg' }, subFileType: 0 }],
|
||||
compatQmsgSceneType: 2,
|
||||
extBizInfo: { pic: {} },
|
||||
uploads: [] as MediaSubFileUpload[],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('pipeline — runNtv2Upload', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(highway.fetchHighwaySession).mockClear();
|
||||
vi.mocked(highway.uploadHighwayHttp).mockClear();
|
||||
vi.mocked(highway.buildHighwayExtend).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setLogLevel(previousLogLevel);
|
||||
});
|
||||
|
||||
it('passes concurrency only for the sub-file that explicitly opts in', async () => {
|
||||
const bridge = makeBridge({
|
||||
responseData: encodeOidbResponse({
|
||||
upload: {
|
||||
uKey: 'main-key',
|
||||
subFileInfos: [{ uKey: 'cover-key' }],
|
||||
msgInfo: { msgInfoBody: [{}, {}], extBizInfo: {} },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const media = {
|
||||
bytes: new Uint8Array([1]),
|
||||
md5: new Uint8Array(16),
|
||||
sha1: new Uint8Array(20),
|
||||
};
|
||||
await runNtv2Upload(
|
||||
baseParams({
|
||||
bridge,
|
||||
uploads: [
|
||||
{ ...media, source: 'top', cmdId: 1005, concurrency: 4 },
|
||||
{ ...media, source: 0, cmdId: 1006 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const calls = vi.mocked(highway.uploadHighwayHttp).mock.calls;
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0]?.[6]).toEqual({ concurrency: 4 });
|
||||
expect(calls[1]?.[6]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('records fast-upload selection and one completed media terminal', async () => {
|
||||
const bridge = makeBridge({
|
||||
responseData: encodeOidbResponse({
|
||||
upload: { msgInfo: { msgInfoBody: [], extBizInfo: {} } },
|
||||
}),
|
||||
});
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
await runNtv2Upload(
|
||||
baseParams({
|
||||
bridge,
|
||||
label: 'image',
|
||||
uploads: [
|
||||
{
|
||||
source: 'top',
|
||||
cmdId: 1003,
|
||||
bytes: new Uint8Array([0xde, 0xad]),
|
||||
md5: new Uint8Array([0x00, 0xff]),
|
||||
sha1: new Uint8Array([0x10, 0x20]),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const trace = highwayTrace(entries);
|
||||
expect(
|
||||
trace.every((entry) => entry.req !== undefined && entry.req === trace[0]!.req),
|
||||
).toBe(true);
|
||||
const messages = trace.map((entry) => entry.message);
|
||||
expect(messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringMatching(
|
||||
/^highway_media_start label="image" .*businessType=1 .*source:"top",cmdId:1003,size:2,storage:"buffer",md5:00ff,sha1:\[1020\]/,
|
||||
),
|
||||
expect.stringContaining('branch=fast_upload'),
|
||||
expect.stringMatching(
|
||||
/^highway_media_terminal label="image" outcome=completed reason=fast_upload .*elapsedMs=\d+$/,
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(messages.join('\n')).not.toContain('dead');
|
||||
expect(
|
||||
messages.filter((message) => message.startsWith('highway_media_terminal ')),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('records required PUT and one failed media terminal', async () => {
|
||||
const bridge = makeBridge({
|
||||
responseData: encodeOidbResponse({
|
||||
upload: {
|
||||
uKey: 'ukey-secret',
|
||||
msgInfo: { msgInfoBody: [], extBizInfo: {} },
|
||||
},
|
||||
}),
|
||||
});
|
||||
vi.mocked(highway.uploadHighwayHttp).mockRejectedValueOnce(new Error('fixture PUT failed'));
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
await expect(
|
||||
runNtv2Upload(
|
||||
baseParams({
|
||||
bridge,
|
||||
label: 'video',
|
||||
uploads: [
|
||||
{
|
||||
source: 'top',
|
||||
cmdId: 1001,
|
||||
bytes: new Uint8Array([1]),
|
||||
md5: new Uint8Array([2]),
|
||||
sha1: new Uint8Array([3]),
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow('fixture PUT failed');
|
||||
const messages = highwayTrace(entries).map((entry) => entry.message);
|
||||
expect(messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('branch=put_required source="top" cmdId=1001 size=1'),
|
||||
expect.stringContaining('uKey="ukey-secret"'),
|
||||
expect.stringContaining('outcome=failed reason=put_failed'),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
messages.filter((message) => message.startsWith('highway_media_terminal ')),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('records OIDB rejection as one failed media terminal', async () => {
|
||||
const bridge = makeBridge({
|
||||
responseData: encodeOidbResponse({}, { errorCode: 42, errorMsg: 'boom' }),
|
||||
});
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
await expect(runNtv2Upload(baseParams({ bridge, label: 'image' }))).rejects.toThrow(
|
||||
'OIDB error 42',
|
||||
);
|
||||
const terminals = highwayTrace(entries).filter((entry) =>
|
||||
entry.message.startsWith('highway_media_terminal '),
|
||||
);
|
||||
expect(terminals).toHaveLength(1);
|
||||
expect(terminals[0]!.message).toContain('outcome=failed reason=oidb_rejected');
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('classifies a thrown OIDB transport call as request_failed', async () => {
|
||||
const bridge = makeBridge();
|
||||
bridge.sendRawPacket.mockRejectedValueOnce(new Error('fixture OIDB pipe failure'));
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
await expect(runNtv2Upload(baseParams({ bridge, label: 'image' }))).rejects.toThrow(
|
||||
'fixture OIDB pipe failure',
|
||||
);
|
||||
const terminals = highwayTrace(entries).filter((entry) =>
|
||||
entry.message.startsWith('highway_media_terminal '),
|
||||
);
|
||||
expect(terminals).toHaveLength(1);
|
||||
expect(terminals[0]!.message).toContain('outcome=failed reason=request_failed');
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('builds an NTV2 request body and dispatches to the configured serviceCmd', async () => {
|
||||
const bridge = makeBridge({
|
||||
responseData: encodeOidbResponse({
|
||||
upload: { msgInfo: { msgInfoBody: [], extBizInfo: {} } },
|
||||
}),
|
||||
});
|
||||
await runNtv2Upload(baseParams({ bridge }));
|
||||
|
||||
expect(bridge.sendRawPacket).toHaveBeenCalledOnce();
|
||||
const [serviceCmd, requestBytes] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(serviceCmd).toBe('OidbSvcTrpcTcp.0x11c4_100');
|
||||
expect((requestBytes as Uint8Array).length).toBeGreaterThan(0);
|
||||
|
||||
// The request roundtrips through the real OIDB envelope and carries
|
||||
// the requestId / businessType / scene fields we passed.
|
||||
const decoded: any = protobuf_decode<OidbBase<NTV2UploadRichMediaReq>>(
|
||||
requestBytes as Uint8Array,
|
||||
);
|
||||
expect(decoded.command).toBe(0x11c4);
|
||||
expect(decoded.body.reqHead.common.requestId).toBe(1);
|
||||
expect(decoded.body.reqHead.scene.businessType).toBe(1);
|
||||
expect(decoded.body.reqHead.scene.sceneType).toBe(2); // isGroup -> 2
|
||||
expect(decoded.body.reqHead.scene.group.groupUin).toBe(12345);
|
||||
});
|
||||
|
||||
it('builds a c2c body when isGroup is false', async () => {
|
||||
const bridge = makeBridge({
|
||||
responseData: encodeOidbResponse({
|
||||
upload: { msgInfo: { msgInfoBody: [], extBizInfo: {} } },
|
||||
}),
|
||||
});
|
||||
await runNtv2Upload(baseParams({ bridge, isGroup: false, targetIdOrUid: 'recipient-uid' }));
|
||||
|
||||
const requestBytes = bridge.sendRawPacket.mock.calls[0]![1];
|
||||
const decoded: any = protobuf_decode<OidbBase<NTV2UploadRichMediaReq>>(
|
||||
requestBytes as Uint8Array,
|
||||
);
|
||||
expect(decoded.body.reqHead.scene.sceneType).toBe(1);
|
||||
expect(decoded.body.reqHead.scene.c2c.targetUid).toBe('recipient-uid');
|
||||
});
|
||||
|
||||
it('returns the decoded `upload` object on success', async () => {
|
||||
const bridge = makeBridge({
|
||||
responseData: encodeOidbResponse({
|
||||
upload: {
|
||||
uKey: 'fake-ukey',
|
||||
msgInfo: { msgInfoBody: [{ fileExist: true }], extBizInfo: {} },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const upload = await runNtv2Upload(baseParams({ bridge }));
|
||||
expect(upload.uKey).toBe('fake-ukey');
|
||||
expect(upload.msgInfo?.msgInfoBody).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects a successful envelope that is missing msgInfo with one failed terminal', async () => {
|
||||
const bridge = makeBridge({
|
||||
responseData: encodeOidbResponse({ upload: { uKey: 'fake-ukey' } }),
|
||||
});
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
await expect(runNtv2Upload(baseParams({ bridge, label: 'image' }))).rejects.toThrow(
|
||||
/upload response missing msgInfo/,
|
||||
);
|
||||
const terminals = highwayTrace(entries).filter((entry) =>
|
||||
entry.message.startsWith('highway_media_terminal '),
|
||||
);
|
||||
expect(terminals).toHaveLength(1);
|
||||
expect(terminals[0]!.message).toContain('outcome=failed reason=response_invalid');
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('throws on transport failure', async () => {
|
||||
const bridge = makeBridge({
|
||||
success: false,
|
||||
errorMessage: 'pipe broken',
|
||||
responseData: null,
|
||||
});
|
||||
await expect(runNtv2Upload(baseParams({ bridge }))).rejects.toThrow(/pipe broken/);
|
||||
});
|
||||
|
||||
it('throws on OIDB errorCode != 0', async () => {
|
||||
const bridge = makeBridge({
|
||||
responseData: encodeOidbResponse({}, { errorCode: 42, errorMsg: 'boom' }),
|
||||
});
|
||||
await expect(runNtv2Upload(baseParams({ bridge }))).rejects.toThrow(/OIDB error 42/);
|
||||
});
|
||||
|
||||
it('throws on body.respHead.retCode != 0', async () => {
|
||||
const bridge = makeBridge({
|
||||
responseData: encodeOidbResponse({
|
||||
respHead: { retCode: 7, message: 'rate-limited' },
|
||||
}),
|
||||
});
|
||||
await expect(runNtv2Upload(baseParams({ bridge }))).rejects.toThrow(/rate-limited/);
|
||||
});
|
||||
|
||||
it('throws "response body missing" when body.upload is absent', async () => {
|
||||
const bridge = makeBridge({ responseData: encodeOidbResponse({}) });
|
||||
await expect(runNtv2Upload(baseParams({ bridge, label: 'image' }))).rejects.toThrow(
|
||||
/image upload response body missing/,
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT run Highway PUT when the server omits uKey (fast-path)', async () => {
|
||||
const bridge = makeBridge({
|
||||
responseData: encodeOidbResponse({
|
||||
upload: { msgInfo: { msgInfoBody: [], extBizInfo: {} } /* no uKey */ },
|
||||
}),
|
||||
});
|
||||
await runNtv2Upload(
|
||||
baseParams({
|
||||
bridge,
|
||||
uploads: [
|
||||
{
|
||||
source: 'top',
|
||||
cmdId: 1003,
|
||||
bytes: new Uint8Array([1, 2, 3]),
|
||||
md5: new Uint8Array(16),
|
||||
sha1: new Uint8Array(20),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(highway.fetchHighwaySession).not.toHaveBeenCalled();
|
||||
expect(highway.uploadHighwayHttp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs Highway PUT for the top-level uKey', async () => {
|
||||
const bridge = makeBridge({
|
||||
responseData: encodeOidbResponse({
|
||||
upload: {
|
||||
uKey: 'ukey-xyz',
|
||||
ipv4s: [{ ipv4: '1.2.3.4', port: 8080 }],
|
||||
msgInfo: { msgInfoBody: [], extBizInfo: {} },
|
||||
},
|
||||
}),
|
||||
});
|
||||
await runNtv2Upload(
|
||||
baseParams({
|
||||
bridge,
|
||||
uploads: [
|
||||
{
|
||||
source: 'top',
|
||||
cmdId: 1003,
|
||||
bytes: new Uint8Array([1, 2, 3]),
|
||||
md5: new Uint8Array(16),
|
||||
sha1: new Uint8Array(20),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(highway.fetchHighwaySession).toHaveBeenCalledOnce();
|
||||
expect(highway.buildHighwayExtend).toHaveBeenCalledOnce();
|
||||
expect(highway.uploadHighwayHttp).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('routes sub-file uploads via upload.subFileInfos[N]', async () => {
|
||||
const bridge = makeBridge({
|
||||
responseData: encodeOidbResponse({
|
||||
upload: {
|
||||
uKey: 'main-ukey',
|
||||
ipv4s: [{ ipv4: '1.1.1.1', port: 80 }],
|
||||
msgInfo: { msgInfoBody: [], extBizInfo: {} },
|
||||
subFileInfos: [{ uKey: 'thumb-ukey', ipv4s: [{ ipv4: '2.2.2.2', port: 81 }] }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
await runNtv2Upload(
|
||||
baseParams({
|
||||
bridge,
|
||||
uploads: [
|
||||
{
|
||||
source: 'top',
|
||||
cmdId: 1001,
|
||||
bytes: new Uint8Array([1]),
|
||||
md5: new Uint8Array(16),
|
||||
sha1: new Uint8Array(20),
|
||||
subFileIndex: 0,
|
||||
},
|
||||
{
|
||||
source: 0,
|
||||
cmdId: 1002,
|
||||
bytes: new Uint8Array([2]),
|
||||
md5: new Uint8Array(16),
|
||||
sha1: new Uint8Array(20),
|
||||
subFileIndex: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// Both Highway PUTs ran; only ONE Highway session was fetched.
|
||||
expect(highway.fetchHighwaySession).toHaveBeenCalledOnce();
|
||||
expect(highway.uploadHighwayHttp).toHaveBeenCalledTimes(2);
|
||||
expect(highway.buildHighwayExtend).toHaveBeenCalledTimes(2);
|
||||
|
||||
// The two Highway PUTs received the right uKeys.
|
||||
const extendCalls = vi.mocked(highway.buildHighwayExtend).mock.calls;
|
||||
expect(extendCalls[0]![0]).toBe('main-ukey');
|
||||
expect(extendCalls[1]![0]).toBe('thumb-ukey');
|
||||
});
|
||||
|
||||
it('throws fastOnlyError when uKey is present but bytes are empty', async () => {
|
||||
const bridge = makeBridge({
|
||||
responseData: encodeOidbResponse({
|
||||
upload: {
|
||||
uKey: 'ukey-xyz',
|
||||
msgInfo: { msgInfoBody: [], extBizInfo: {} },
|
||||
},
|
||||
}),
|
||||
});
|
||||
await expect(
|
||||
runNtv2Upload(
|
||||
baseParams({
|
||||
bridge,
|
||||
uploads: [
|
||||
{
|
||||
source: 'top',
|
||||
cmdId: 1003,
|
||||
bytes: new Uint8Array(0),
|
||||
md5: new Uint8Array(16),
|
||||
sha1: new Uint8Array(20),
|
||||
fastOnlyError: 'image fast-upload not available',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(/image fast-upload not available/);
|
||||
});
|
||||
|
||||
// (Removed the forceFullOnFastPath retry tests with the feature itself — real-
|
||||
// machine testing + kernel RE proved the tryFastUploadCompleted:false retry
|
||||
// never forces a fresh upload; expired-but-indexed video resources are a
|
||||
// platform limitation. See #145.)
|
||||
|
||||
it('silently skips a sub-file with empty bytes when no fastOnlyError is configured', async () => {
|
||||
// This mirrors the video-thumb path where bytes are always synthesised
|
||||
// (FALLBACK_THUMB) so we don't expect the check to fire even if the
|
||||
// server returns an empty thumb uKey alongside a present main uKey.
|
||||
const bridge = makeBridge({
|
||||
responseData: encodeOidbResponse({
|
||||
upload: {
|
||||
uKey: 'main-ukey',
|
||||
msgInfo: { msgInfoBody: [], extBizInfo: {} },
|
||||
subFileInfos: [{ uKey: 'thumb-ukey' }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
await runNtv2Upload(
|
||||
baseParams({
|
||||
bridge,
|
||||
uploads: [
|
||||
{
|
||||
source: 'top',
|
||||
cmdId: 1001,
|
||||
bytes: new Uint8Array([1]),
|
||||
md5: new Uint8Array(16),
|
||||
sha1: new Uint8Array(20),
|
||||
},
|
||||
{
|
||||
source: 0,
|
||||
cmdId: 1002,
|
||||
bytes: new Uint8Array(0),
|
||||
md5: new Uint8Array(16),
|
||||
sha1: new Uint8Array(20),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(highway.uploadHighwayHttp).toHaveBeenCalledOnce(); // only main
|
||||
});
|
||||
});
|
||||
|
||||
describe('pipeline — finalizeMediaMsgInfo', () => {
|
||||
it('throws when msgInfo is missing', () => {
|
||||
expect(() => finalizeMediaMsgInfo({})).toThrow(/missing msgInfo/);
|
||||
});
|
||||
|
||||
it('encodes msgInfoBody + extBizInfo from the server response', () => {
|
||||
const out = finalizeMediaMsgInfo({
|
||||
msgInfo: {
|
||||
msgInfoBody: [{ index: { subType: 1 }, fileExist: true }],
|
||||
extBizInfo: { busiType: 1 },
|
||||
},
|
||||
});
|
||||
expect(out).toBeInstanceOf(Uint8Array);
|
||||
expect(out.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('with defaultPic, fills missing pic.bizType + pic.textSummary', () => {
|
||||
// Encode the result and decode it back via the same schema to inspect
|
||||
// what actually landed in the proto.
|
||||
const out = finalizeMediaMsgInfo(
|
||||
{
|
||||
msgInfo: { msgInfoBody: [], extBizInfo: {} },
|
||||
},
|
||||
{ bizType: 7, textSummary: '[image]' },
|
||||
);
|
||||
|
||||
const decoded: any = protobuf_decode<EncodableMediaMsgInfo>(out);
|
||||
expect(decoded.extBizInfo.pic.bizType).toBe(7);
|
||||
expect(decoded.extBizInfo.pic.textSummary).toBe('[image]');
|
||||
});
|
||||
|
||||
it('preserves a server ptt waveform on the outgoing MsgInfo', () => {
|
||||
const waveform = new Uint8Array([8, 4, 18, 4, 10, 20, 30, 40]);
|
||||
const out = finalizeMediaMsgInfo({
|
||||
msgInfo: { msgInfoBody: [], extBizInfo: { ptt: { waveform } } },
|
||||
});
|
||||
const decoded: any = protobuf_decode<EncodableMediaMsgInfo>(out);
|
||||
expect(decoded.extBizInfo.ptt.waveform).toEqual(waveform);
|
||||
});
|
||||
|
||||
it('without defaultPic, leaves pic untouched when server omits it', () => {
|
||||
const out = finalizeMediaMsgInfo({
|
||||
msgInfo: { msgInfoBody: [], extBizInfo: {} },
|
||||
});
|
||||
const decoded: any = protobuf_decode<EncodableMediaMsgInfo>(out);
|
||||
// An empty extBizInfo proto roundtrips to undefined; either way the
|
||||
// pic field never got materialised.
|
||||
expect(decoded?.extBizInfo?.pic ?? undefined).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pipeline — helpers', () => {
|
||||
it('hexToBytes roundtrips even-length hex', () => {
|
||||
expect([...hexToBytes('deadbeef')]).toEqual([0xde, 0xad, 0xbe, 0xef]);
|
||||
});
|
||||
|
||||
it('hexToBytes left-pads odd-length hex with a leading zero', () => {
|
||||
expect([...hexToBytes('abc')]).toEqual([0x0a, 0xbc]);
|
||||
});
|
||||
|
||||
it('makeClientRandomId produces a non-zero positive BigInt', () => {
|
||||
const id = makeClientRandomId();
|
||||
expect(typeof id).toBe('bigint');
|
||||
expect(id).toBeGreaterThan(0n);
|
||||
expect(id).toBeLessThan(0x8000000000000000n);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
// ptt-upload tests focus on the fingerprint (fast-upload) path: the
|
||||
// regular load path goes through ffmpeg's silk encoder + the OS temp
|
||||
// directory, which we don't want to exercise in a unit test. The
|
||||
// fingerprint path covers most of what makes ptt-upload distinct
|
||||
// (group/c2c requestId difference, command id mapping, the group-only
|
||||
// bytesGeneralFlags blob, voiceFormat honouring).
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('@snowluma/protocol/highway/pipeline', () => ({
|
||||
runNtv2Upload: vi.fn(async () => ({ msgInfo: { msgInfoBody: [], extBizInfo: {} } })),
|
||||
finalizeMediaMsgInfo: vi.fn(() => new Uint8Array([0xaa, 0xbb])),
|
||||
hexToBytes: vi.fn((hex: string) => new Uint8Array(hex.length / 2)),
|
||||
}));
|
||||
|
||||
import * as pipeline from '@snowluma/protocol/highway/pipeline';
|
||||
import {
|
||||
uploadPttMsgInfo,
|
||||
GROUP_PTT_CMD_ID,
|
||||
PRIVATE_PTT_CMD_ID,
|
||||
} from '@snowluma/protocol/highway/ptt-upload';
|
||||
|
||||
const FINGERPRINT = {
|
||||
noByteFallback: true,
|
||||
md5Hex: 'aa',
|
||||
sha1Hex: 'bb',
|
||||
fileSize: 1234,
|
||||
duration: 5,
|
||||
} as any;
|
||||
|
||||
describe('ptt-upload', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(pipeline.runNtv2Upload).mockClear();
|
||||
vi.mocked(pipeline.finalizeMediaMsgInfo).mockClear();
|
||||
});
|
||||
|
||||
it('retains successful upload metadata for self-sent history', async () => {
|
||||
const node = { fileUuid: 'uploaded-resource', storeId: 1, info: { fileSize: 1234 } };
|
||||
vi.mocked(pipeline.runNtv2Upload).mockResolvedValueOnce({
|
||||
msgInfo: { msgInfoBody: [{ index: node }] },
|
||||
} as any);
|
||||
const element = { ...FINGERPRINT };
|
||||
await uploadPttMsgInfo({} as any, true, 12345, element);
|
||||
expect(element).toMatchObject({
|
||||
fileId: 'uploaded-resource',
|
||||
mediaNode: node,
|
||||
md5Hex: 'aa',
|
||||
sha1Hex: 'bb',
|
||||
});
|
||||
expect(element.fileName).toBeTruthy();
|
||||
});
|
||||
|
||||
it('group: 0x126E_100 + GROUP_PTT_CMD_ID + group-flavored bytesGeneralFlags', async () => {
|
||||
await uploadPttMsgInfo({} as any, true, 12345, FINGERPRINT);
|
||||
const args = vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0];
|
||||
expect(args.oidbCmd).toBe(0x126e);
|
||||
expect(args.serviceCmd).toBe('OidbSvcTrpcTcp.0x126e_100');
|
||||
expect(args.requestId).toBe(1); // group
|
||||
expect(args.businessType).toBe(3);
|
||||
expect(args.uploads[0]!.cmdId).toBe(GROUP_PTT_CMD_ID);
|
||||
|
||||
const groupFlags = (args.extBizInfo as any).ptt.bytesGeneralFlags;
|
||||
expect(groupFlags).toEqual(
|
||||
new Uint8Array([0x9a, 0x01, 0x07, 0xaa, 0x03, 0x04, 0x08, 0x08, 0x12, 0x00]),
|
||||
);
|
||||
});
|
||||
|
||||
it('c2c: 0x126D_100 + PRIVATE_PTT_CMD_ID + requestId=4 + no generalFlags', async () => {
|
||||
await uploadPttMsgInfo({} as any, false, 'recipient-uid', FINGERPRINT);
|
||||
const args = vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0];
|
||||
expect(args.oidbCmd).toBe(0x126d);
|
||||
expect(args.serviceCmd).toBe('OidbSvcTrpcTcp.0x126d_100');
|
||||
expect(args.requestId).toBe(4);
|
||||
expect(args.uploads[0]!.cmdId).toBe(PRIVATE_PTT_CMD_ID);
|
||||
|
||||
// Private voice must not carry a generalFlags blob: with the NapCat c2c
|
||||
// one present the receiver drops the progress bar even though the waveform
|
||||
// is in the payload. Group keeps its 10-byte blob (covered above).
|
||||
expect((args.extBizInfo as any).ptt.bytesGeneralFlags).toBeUndefined();
|
||||
expect((args.extBizInfo as any).ptt.waveform).toBeUndefined();
|
||||
|
||||
// Reserve stays the bare legacy 4 bytes, same as group voice.
|
||||
expect([...(args.extBizInfo as any).ptt.bytesReserve]).toEqual([0x08, 0x00, 0x38, 0x00]);
|
||||
});
|
||||
|
||||
it('fingerprint path does not invent a waveform', async () => {
|
||||
await uploadPttMsgInfo({} as any, true, 12345, FINGERPRINT);
|
||||
const args = vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0];
|
||||
expect((args.extBizInfo as any).ptt.waveform).toBeUndefined();
|
||||
expect(pipeline.finalizeMediaMsgInfo).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('fingerprint payload: zero bytes, fastOnlyError ready, voiceFormat defaults to 1', async () => {
|
||||
await uploadPttMsgInfo({} as any, true, 12345, FINGERPRINT);
|
||||
const args = vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0];
|
||||
expect(args.uploads[0]!.bytes.length).toBe(0);
|
||||
expect(args.uploads[0]!.fastOnlyError).toMatch(/record fast-upload not available/);
|
||||
const fileInfo: any = (args.uploadInfo[0] as any).fileInfo;
|
||||
expect(fileInfo.type.voiceFormat).toBe(1);
|
||||
expect(fileInfo.time).toBe(5);
|
||||
});
|
||||
|
||||
it('honours an explicit voiceFormat from the fingerprint', async () => {
|
||||
await uploadPttMsgInfo({} as any, true, 12345, { ...FINGERPRINT, voiceFormat: 2 });
|
||||
const fileInfo: any = (
|
||||
vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0].uploadInfo[0] as any
|
||||
).fileInfo;
|
||||
expect(fileInfo.type.voiceFormat).toBe(2);
|
||||
});
|
||||
|
||||
it('fingerprint mode rejects when md5Hex or sha1Hex is missing', async () => {
|
||||
await expect(
|
||||
uploadPttMsgInfo({} as any, true, 12345, { noByteFallback: true } as any),
|
||||
).rejects.toThrow(/requires md5Hex/);
|
||||
});
|
||||
|
||||
it('finalize is called without a defaultPic (ptt copies pic from server only)', async () => {
|
||||
await uploadPttMsgInfo({} as any, true, 12345, FINGERPRINT);
|
||||
const args = vi.mocked(pipeline.finalizeMediaMsgInfo).mock.calls[0]!;
|
||||
expect(args[1]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
// stageSourceToDisk unifies every upload source onto a local disk path so the
|
||||
// streaming uploader never buffers a whole file. These tests cover the three
|
||||
// source kinds, the crash-safety contract (hardlink survives caller-side
|
||||
// deletion — the retention race Codex flagged), size caps, symlink resolution,
|
||||
// cleanup, and the orphan reaper's active-set safety.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { promises as fsp } from 'fs';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { stageSourceToDisk, sweepStagedUploads, STAGE_DIR } from '@snowluma/protocol/highway/stage';
|
||||
import { pathToFileURL } from 'url';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
let scratch: string;
|
||||
|
||||
async function readStaged(p: string): Promise<Buffer> {
|
||||
return fsp.readFile(p);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
scratch = await fsp.mkdtemp(path.join(os.tmpdir(), 'sl-stage-src-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
await fsp.rm(scratch, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('stageSourceToDisk — base64', () => {
|
||||
it('stages base64 bytes to disk', async () => {
|
||||
const data = Buffer.from('hello base64 world');
|
||||
const staged = await stageSourceToDisk(`base64://${data.toString('base64')}`, 1024);
|
||||
|
||||
try {
|
||||
expect(staged.fileSize).toBe(data.length);
|
||||
expect(staged.fileName).toBe('');
|
||||
expect((await readStaged(staged.filePath)).equals(data)).toBe(true);
|
||||
expect(staged.filePath.startsWith(STAGE_DIR)).toBe(true);
|
||||
} finally {
|
||||
await staged.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('stages base64 data URLs to disk', async () => {
|
||||
const data = Buffer.from('hello data URL');
|
||||
const staged = await stageSourceToDisk(
|
||||
`data:audio/webm;codecs=opus;base64,${data.toString('base64')}`,
|
||||
1024,
|
||||
);
|
||||
|
||||
try {
|
||||
expect(staged.fileSize).toBe(data.length);
|
||||
expect(staged.fileName).toBe('');
|
||||
expect((await readStaged(staged.filePath)).equals(data)).toBe(true);
|
||||
} finally {
|
||||
await staged.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects oversized base64 on decoded length (before allocation) and leaves no temp', async () => {
|
||||
const big = Buffer.alloc(4096).toString('base64');
|
||||
await expect(stageSourceToDisk(`base64://${big}`, 64)).rejects.toThrow(/too large/);
|
||||
const parts = (await fsp.readdir(STAGE_DIR).catch(() => [] as string[])).filter((n) =>
|
||||
n.endsWith('.part'),
|
||||
);
|
||||
expect(parts).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects oversized base64 data URLs before allocation and leaves no temp', async () => {
|
||||
const big = Buffer.alloc(4096).toString('base64');
|
||||
await expect(stageSourceToDisk(`data:audio/webm;base64,${big}`, 64)).rejects.toThrow(
|
||||
/too large/,
|
||||
);
|
||||
const parts = (await fsp.readdir(STAGE_DIR).catch(() => [] as string[])).filter((n) =>
|
||||
n.endsWith('.part'),
|
||||
);
|
||||
expect(parts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stageSourceToDisk — http', () => {
|
||||
it('streams an http download to disk with the URL basename', async () => {
|
||||
const body = new Uint8Array([9, 8, 7, 6, 5]);
|
||||
globalThis.fetch = (async () => new Response(body, { status: 200 })) as typeof fetch;
|
||||
const staged = await stageSourceToDisk('https://example.test/clip.mp4', 1024);
|
||||
|
||||
try {
|
||||
expect(staged.fileName).toBe('clip.mp4');
|
||||
expect(staged.fileSize).toBe(body.length);
|
||||
expect((await readStaged(staged.filePath)).equals(Buffer.from(body))).toBe(true);
|
||||
} finally {
|
||||
await staged.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an oversized http download and cleans up the .part', async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(new Uint8Array(4096), {
|
||||
status: 200,
|
||||
headers: { 'content-length': '4096' },
|
||||
})) as typeof fetch;
|
||||
await expect(stageSourceToDisk('https://example.test/big.bin', 64)).rejects.toThrow(
|
||||
/too large/,
|
||||
);
|
||||
const leftover = (await fsp.readdir(STAGE_DIR).catch(() => [] as string[])).filter((n) =>
|
||||
n.endsWith('.part'),
|
||||
);
|
||||
expect(leftover).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a STREAMED overflow (no Content-Length) after the sink+.part exist, then cleans up', async () => {
|
||||
// No content-length → the size cap only trips mid-stream, AFTER fileSink has
|
||||
// opened the .part. This exercises fileSink.discard()'s close-before-unlink.
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
c.enqueue(new Uint8Array(4096));
|
||||
}
|
||||
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
globalThis.fetch = (async () => new Response(stream, { status: 200 })) as typeof fetch;
|
||||
await expect(
|
||||
stageSourceToDisk('https://example.test/stream.bin', 4 * 1024),
|
||||
).rejects.toThrow(/too large/);
|
||||
const parts = (await fsp.readdir(STAGE_DIR).catch(() => [] as string[])).filter((n) =>
|
||||
n.endsWith('.part'),
|
||||
);
|
||||
expect(parts).toEqual([]);
|
||||
});
|
||||
|
||||
it('retries with a Referer after a network failure and stages the retry body cleanly (no attempt-1 leftovers)', async () => {
|
||||
const body = new Uint8Array([1, 2, 3, 4, 5, 6, 7]);
|
||||
let n = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
n += 1;
|
||||
|
||||
if (n === 1) {
|
||||
throw new TypeError('fetch failed');
|
||||
} // anti-bot RST on attempt 1
|
||||
|
||||
return new Response(body, { status: 200 });
|
||||
}) as typeof fetch;
|
||||
const staged = await stageSourceToDisk('https://example.test/r.mp4', 1024);
|
||||
|
||||
try {
|
||||
expect(n).toBe(2);
|
||||
expect(staged.fileSize).toBe(body.length);
|
||||
expect((await readStaged(staged.filePath)).equals(Buffer.from(body))).toBe(true);
|
||||
} finally {
|
||||
await staged.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('stageSourceToDisk — local files', () => {
|
||||
it('hardlinks a local file:// source and survives caller-side deletion (retention race)', async () => {
|
||||
const src = path.join(scratch, 'video.mp4');
|
||||
const data = Buffer.from('the caller-owned temp that retention will delete');
|
||||
fs.writeFileSync(src, data);
|
||||
|
||||
const staged = await stageSourceToDisk(pathToFileURL(src).href, 1 << 20);
|
||||
|
||||
try {
|
||||
expect(staged.fileName).toBe('video.mp4');
|
||||
expect(staged.fileSize).toBe(data.length);
|
||||
// Simulate upload_file_stream's retention timer unlinking the original
|
||||
// BETWEEN the hash pass and the upload pass.
|
||||
await fsp.unlink(src);
|
||||
// The hardlink kept the inode alive — the staged path is still fully readable.
|
||||
expect((await readStaged(staged.filePath)).equals(data)).toBe(true);
|
||||
} finally {
|
||||
await staged.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts a raw local path and preserves its basename', async () => {
|
||||
const src = path.join(scratch, 'doc.bin');
|
||||
fs.writeFileSync(src, Buffer.from([1, 2, 3, 4]));
|
||||
const staged = await stageSourceToDisk(src, 1 << 20);
|
||||
|
||||
try {
|
||||
expect(staged.fileName).toBe('doc.bin');
|
||||
expect(staged.fileSize).toBe(4);
|
||||
} finally {
|
||||
await staged.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves a symlink to its target but keeps the symlink basename', async () => {
|
||||
const target = path.join(scratch, 'real-xyz.tmp');
|
||||
const linkPath = path.join(scratch, 'friendly.mp4');
|
||||
const data = Buffer.from('symlinked content');
|
||||
fs.writeFileSync(target, data);
|
||||
fs.symlinkSync(target, linkPath);
|
||||
const staged = await stageSourceToDisk(linkPath, 1 << 20);
|
||||
|
||||
try {
|
||||
expect(staged.fileName).toBe('friendly.mp4'); // original basename, not the target's
|
||||
expect((await readStaged(staged.filePath)).equals(data)).toBe(true);
|
||||
} finally {
|
||||
await staged.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to a streamed copy when hardlink fails (e.g. cross-filesystem EXDEV)', async () => {
|
||||
const src = path.join(scratch, 'xfs.mp4');
|
||||
const data = Buffer.from('content that must copy when link() is refused');
|
||||
fs.writeFileSync(src, data);
|
||||
const linkSpy = vi
|
||||
.spyOn(fsp, 'link')
|
||||
.mockRejectedValueOnce(Object.assign(new Error('EXDEV'), { code: 'EXDEV' }));
|
||||
|
||||
try {
|
||||
const staged = await stageSourceToDisk(src, 1 << 20);
|
||||
|
||||
try {
|
||||
expect(linkSpy).toHaveBeenCalledOnce();
|
||||
expect(staged.fileName).toBe('xfs.mp4');
|
||||
expect(staged.fileSize).toBe(data.length);
|
||||
expect((await readStaged(staged.filePath)).equals(data)).toBe(true);
|
||||
// The copy is an independent file — deleting the source leaves it intact.
|
||||
await fsp.unlink(src);
|
||||
expect((await readStaged(staged.filePath)).equals(data)).toBe(true);
|
||||
} finally {
|
||||
await staged.cleanup();
|
||||
}
|
||||
} finally {
|
||||
linkSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a non-regular file (directory)', async () => {
|
||||
await expect(stageSourceToDisk(scratch, 1 << 20)).rejects.toThrow(/not a regular file/);
|
||||
});
|
||||
|
||||
it('rejects an oversized local file before staging', async () => {
|
||||
const src = path.join(scratch, 'huge.bin');
|
||||
fs.writeFileSync(src, Buffer.alloc(200));
|
||||
await expect(stageSourceToDisk(src, 64)).rejects.toThrow(/too large: 200 > 64/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stageSourceToDisk — cleanup + reaper', () => {
|
||||
it('cleanup removes the staged temp and is idempotent', async () => {
|
||||
const src = path.join(scratch, 'c.bin');
|
||||
fs.writeFileSync(src, Buffer.from([1]));
|
||||
const staged = await stageSourceToDisk(src, 1 << 20);
|
||||
expect(fs.existsSync(staged.filePath)).toBe(true);
|
||||
await staged.cleanup();
|
||||
expect(fs.existsSync(staged.filePath)).toBe(false);
|
||||
await expect(staged.cleanup()).resolves.toBeUndefined(); // idempotent, no throw
|
||||
});
|
||||
|
||||
it('reaper removes an old ORPHAN temp but NEVER an in-flight (active) staged file', async () => {
|
||||
await fsp.mkdir(STAGE_DIR, { recursive: true });
|
||||
const old = Date.now() - 60 * 60 * 1000; // 1h ago
|
||||
|
||||
// Orphan: a temp NOT tracked in `active` (as a crash would leave), aged out.
|
||||
const orphan = path.join(STAGE_DIR, `stage-${randomUUID()}.orphan`);
|
||||
fs.writeFileSync(orphan, Buffer.from('crash leftover'));
|
||||
fs.utimesSync(orphan, new Date(old), new Date(old));
|
||||
|
||||
// In-flight: a real staged file (registered in `active`), also aged out —
|
||||
// the active-set must protect it from the age sweep.
|
||||
const src = path.join(scratch, 'inflight.bin');
|
||||
fs.writeFileSync(src, Buffer.from('being uploaded'));
|
||||
const staged = await stageSourceToDisk(src, 1 << 20);
|
||||
fs.utimesSync(staged.filePath, new Date(old), new Date(old)); // read-only upload → stale mtime
|
||||
|
||||
const removed = await sweepStagedUploads(Date.now());
|
||||
expect(fs.existsSync(orphan)).toBe(false); // orphan reaped
|
||||
expect(fs.existsSync(staged.filePath)).toBe(true); // active file preserved
|
||||
expect(removed).toBeGreaterThanOrEqual(1);
|
||||
|
||||
await staged.cleanup();
|
||||
});
|
||||
|
||||
it('reaper leaves a recent orphan alone', async () => {
|
||||
await fsp.mkdir(STAGE_DIR, { recursive: true });
|
||||
const recent = path.join(STAGE_DIR, `stage-${randomUUID()}.recent`);
|
||||
fs.writeFileSync(recent, Buffer.from('fresh'));
|
||||
|
||||
try {
|
||||
await sweepStagedUploads(Date.now());
|
||||
expect(fs.existsSync(recent)).toBe(true);
|
||||
} finally {
|
||||
await fsp.rm(recent, { force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
import {
|
||||
getVideoUploadConcurrency,
|
||||
setVideoUploadConcurrency,
|
||||
} from '@snowluma/protocol/highway/upload-settings';
|
||||
// video-upload tests cover the fingerprint (fast-upload) path. The
|
||||
// regular load path exercises ffmpeg + the OS temp dir + a streaming
|
||||
// SHA1 implementation; that machinery has its own seams worth testing
|
||||
// separately, and isn't what makes video-upload distinct at the API
|
||||
// surface.
|
||||
//
|
||||
// What we're checking here: the two-sub-file shape (main + thumb), the
|
||||
// thumb's source routing (`upload.subFileInfos[0]`), the per-sub-file
|
||||
// fastOnlyError difference (main has it; thumb doesn't — FALLBACK_THUMB
|
||||
// always provides bytes), and the video-specific OIDB fields.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { promises as fsp } from 'fs';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { computeHashes } from '@snowluma/protocol/highway/utils';
|
||||
|
||||
vi.mock('@snowluma/protocol/highway/pipeline', () => ({
|
||||
runNtv2Upload: vi.fn(async () => ({ msgInfo: { msgInfoBody: [], extBizInfo: {} } })),
|
||||
finalizeMediaMsgInfo: vi.fn(() => new Uint8Array([0x12, 0x34])),
|
||||
hexToBytes: vi.fn((hex: string) => new Uint8Array(hex.length / 2)),
|
||||
}));
|
||||
|
||||
import * as pipeline from '@snowluma/protocol/highway/pipeline';
|
||||
import {
|
||||
uploadVideoMsgInfo,
|
||||
GROUP_VIDEO_CMD_ID,
|
||||
GROUP_VIDEO_THUMB_CMD_ID,
|
||||
PRIVATE_VIDEO_CMD_ID,
|
||||
PRIVATE_VIDEO_THUMB_CMD_ID,
|
||||
} from '@snowluma/protocol/highway/video-upload';
|
||||
|
||||
const FINGERPRINT = {
|
||||
noByteFallback: true,
|
||||
md5Hex: 'aa',
|
||||
sha1Hex: 'bb',
|
||||
fileSize: 1024,
|
||||
width: 320,
|
||||
height: 240,
|
||||
duration: 10,
|
||||
videoFormat: 0,
|
||||
} as any;
|
||||
|
||||
describe('video-upload', () => {
|
||||
afterEach(() => setVideoUploadConcurrency(1));
|
||||
beforeEach(() => {
|
||||
vi.mocked(pipeline.runNtv2Upload).mockClear();
|
||||
vi.mocked(pipeline.finalizeMediaMsgInfo).mockClear();
|
||||
});
|
||||
|
||||
it('snapshots the global concurrency for video bodies without parallelizing covers', async () => {
|
||||
expect(getVideoUploadConcurrency()).toBe(1);
|
||||
setVideoUploadConcurrency(4);
|
||||
const first = uploadVideoMsgInfo({} as any, true, 12345, FINGERPRINT);
|
||||
setVideoUploadConcurrency(2);
|
||||
await first;
|
||||
const firstUpload = vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0];
|
||||
expect(firstUpload.uploads[0]!.concurrency).toBe(4);
|
||||
expect(firstUpload.uploads[1]!.concurrency).toBeUndefined();
|
||||
await uploadVideoMsgInfo({} as any, false, 'recipient-uid', FINGERPRINT);
|
||||
const secondUpload = vi.mocked(pipeline.runNtv2Upload).mock.calls[1]![0];
|
||||
expect(secondUpload.uploads[0]!.concurrency).toBe(2);
|
||||
expect(secondUpload.uploads[1]!.concurrency).toBeUndefined();
|
||||
});
|
||||
|
||||
it('retains successful upload metadata for self-sent history', async () => {
|
||||
const node = { fileUuid: 'uploaded-resource', storeId: 1, info: { fileSize: 1234 } };
|
||||
vi.mocked(pipeline.runNtv2Upload).mockResolvedValueOnce({
|
||||
msgInfo: { msgInfoBody: [{ index: node }] },
|
||||
} as any);
|
||||
const element = { ...FINGERPRINT };
|
||||
await uploadVideoMsgInfo({} as any, true, 12345, element);
|
||||
expect(element).toMatchObject({
|
||||
fileId: 'uploaded-resource',
|
||||
mediaNode: node,
|
||||
md5Hex: 'aa',
|
||||
sha1Hex: 'bb',
|
||||
});
|
||||
expect(element.fileName).toBeTruthy();
|
||||
});
|
||||
|
||||
it('group: 0x11EA_100 + GROUP_VIDEO_CMD_ID for main, GROUP_VIDEO_THUMB_CMD_ID for thumb', async () => {
|
||||
await uploadVideoMsgInfo({} as any, true, 12345, FINGERPRINT);
|
||||
const args = vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0];
|
||||
expect(args.oidbCmd).toBe(0x11ea);
|
||||
expect(args.serviceCmd).toBe('OidbSvcTrpcTcp.0x11ea_100');
|
||||
expect(args.requestId).toBe(3);
|
||||
expect(args.businessType).toBe(2);
|
||||
expect(args.compatQmsgSceneType).toBe(2);
|
||||
expect(args.uploads).toHaveLength(2);
|
||||
expect(args.uploads[0]!.cmdId).toBe(GROUP_VIDEO_CMD_ID);
|
||||
expect(args.uploads[1]!.cmdId).toBe(GROUP_VIDEO_THUMB_CMD_ID);
|
||||
});
|
||||
|
||||
it('c2c: 0x11E9_100 + PRIVATE cmd ids', async () => {
|
||||
await uploadVideoMsgInfo({} as any, false, 'recipient-uid', FINGERPRINT);
|
||||
const args = vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0];
|
||||
expect(args.oidbCmd).toBe(0x11e9);
|
||||
expect(args.uploads[0]!.cmdId).toBe(PRIVATE_VIDEO_CMD_ID);
|
||||
expect(args.uploads[1]!.cmdId).toBe(PRIVATE_VIDEO_THUMB_CMD_ID);
|
||||
});
|
||||
|
||||
it('main file routes via "top", thumb routes via subFileInfos[0]', async () => {
|
||||
await uploadVideoMsgInfo({} as any, true, 12345, FINGERPRINT);
|
||||
const uploads = vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0].uploads;
|
||||
expect(uploads[0]!.source).toBe('top');
|
||||
expect(uploads[0]!.subFileIndex).toBe(0);
|
||||
expect(uploads[1]!.source).toBe(0);
|
||||
expect(uploads[1]!.subFileIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('main fastOnlyError is set; thumb is silent (FALLBACK_THUMB always has bytes)', async () => {
|
||||
await uploadVideoMsgInfo({} as any, true, 12345, FINGERPRINT);
|
||||
const uploads = vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0].uploads;
|
||||
expect(uploads[0]!.fastOnlyError).toMatch(/video fast-upload not available/);
|
||||
expect(uploads[1]!.fastOnlyError).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT set a force-full retry flag on the main file (#145 — proven ineffective)', async () => {
|
||||
// The old forceFullOnFastPath retry was removed: real-machine + kernel RE
|
||||
// showed it never forces a fresh upload (the server fast-paths by md5
|
||||
// metadata regardless of tryFastUploadCompleted). An expired-but-indexed
|
||||
// video resource is a platform limitation, not an upload-flow fix.
|
||||
await uploadVideoMsgInfo({} as any, true, 12345, FINGERPRINT);
|
||||
const uploads = vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0].uploads;
|
||||
expect(
|
||||
(uploads[0] as unknown as Record<string, unknown>).forceFullOnFastPath,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uploadInfo carries TWO entries (main mp4 + thumb jpg) with subFileType 0 and 100', async () => {
|
||||
await uploadVideoMsgInfo({} as any, true, 12345, FINGERPRINT);
|
||||
const uploadInfo = vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0].uploadInfo;
|
||||
expect(uploadInfo).toHaveLength(2);
|
||||
expect((uploadInfo[0] as any).subFileType).toBe(0);
|
||||
expect((uploadInfo[1] as any).subFileType).toBe(100);
|
||||
expect((uploadInfo[0] as any).fileInfo.type.type).toBe(2); // video
|
||||
expect((uploadInfo[1] as any).fileInfo.type.type).toBe(1); // pic (thumb)
|
||||
});
|
||||
|
||||
it('[#145] group video carries real width/height; c2c stays 0/0', async () => {
|
||||
// A real QQ group video's MsgInfo has real dimensions (e.g. 296x640);
|
||||
// sending 0x0 makes QQ-NT receivers render 文件已过期 even though the
|
||||
// resource is fresh (verified on tempserver: 0x0 → expired, real dims →
|
||||
// renders). c2c is left at 0 — the server rejects non-zero dims there.
|
||||
await uploadVideoMsgInfo({} as any, true, 12345, FINGERPRINT);
|
||||
const groupInfo = vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0].uploadInfo;
|
||||
expect((groupInfo[0] as any).fileInfo.width).toBe(320); // FINGERPRINT.width
|
||||
expect((groupInfo[0] as any).fileInfo.height).toBe(240); // FINGERPRINT.height
|
||||
|
||||
vi.mocked(pipeline.runNtv2Upload).mockClear();
|
||||
await uploadVideoMsgInfo({} as any, false, 'recipient-uid', FINGERPRINT);
|
||||
const c2cInfo = vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0].uploadInfo;
|
||||
expect((c2cInfo[0] as any).fileInfo.width).toBe(0);
|
||||
expect((c2cInfo[0] as any).fileInfo.height).toBe(0);
|
||||
});
|
||||
|
||||
it('[#145] fast-upload with 0x0 dims (e.g. forwarding a dimensionless video) falls back to portrait for group', async () => {
|
||||
// A forwarded video whose cached dims are 0 must NOT ship 0x0 to a group
|
||||
// (→ 文件已过期). videoPayloadFromFingerprint substitutes a neutral 720x1280
|
||||
// portrait; c2c stays 0 (server rejects non-zero there).
|
||||
const noDims = { ...FINGERPRINT, width: 0, height: 0 } as any;
|
||||
await uploadVideoMsgInfo({} as any, true, 12345, noDims);
|
||||
const groupInfo = vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0].uploadInfo;
|
||||
expect((groupInfo[0] as any).fileInfo.width).toBe(720);
|
||||
expect((groupInfo[0] as any).fileInfo.height).toBe(1280);
|
||||
|
||||
vi.mocked(pipeline.runNtv2Upload).mockClear();
|
||||
await uploadVideoMsgInfo({} as any, false, 'recipient-uid', noDims);
|
||||
const c2cInfo = vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0].uploadInfo;
|
||||
expect((c2cInfo[0] as any).fileInfo.width).toBe(0);
|
||||
expect((c2cInfo[0] as any).fileInfo.height).toBe(0);
|
||||
});
|
||||
|
||||
it('[#145] zero-trust: fallback cover declared dims equal the video fallback (content == declared)', async () => {
|
||||
// The fast-upload cover is a real 720x1280 image; the thumb sub-file must
|
||||
// declare those same pixels, not a 1x1 lying about its size, and they match
|
||||
// the video fallback so tile + cover agree.
|
||||
const noDims = { ...FINGERPRINT, width: 0, height: 0 } as any;
|
||||
await uploadVideoMsgInfo({} as any, true, 12345, noDims);
|
||||
const info = vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0].uploadInfo;
|
||||
expect((info[0] as any).fileInfo.width).toBe(720); // video tile
|
||||
expect((info[0] as any).fileInfo.height).toBe(1280);
|
||||
expect((info[1] as any).fileInfo.width).toBe(720); // cover sub-file (subFileType 100)
|
||||
expect((info[1] as any).fileInfo.height).toBe(1280);
|
||||
});
|
||||
|
||||
it('[#145] zero-trust: cached duration 0 → time 1, never 00:00', async () => {
|
||||
const noDur = { ...FINGERPRINT, duration: 0 } as any;
|
||||
await uploadVideoMsgInfo({} as any, true, 12345, noDur);
|
||||
const info = vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0].uploadInfo;
|
||||
expect((info[0] as any).fileInfo.time).toBe(1);
|
||||
});
|
||||
|
||||
it('[#145] real video dims are preserved on the main file even though the cover is the 720x1280 fallback', async () => {
|
||||
// FINGERPRINT has real 320x240 dims; the fast-upload cover is always the
|
||||
// 720x1280 fallback (no real cover cached on the forward path). The video
|
||||
// tile keeps the real dims — the cover/tile aspect mismatch is tolerated,
|
||||
// only a 0x0 MAIN file triggers 文件已过期.
|
||||
await uploadVideoMsgInfo({} as any, true, 12345, FINGERPRINT);
|
||||
const info = vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0].uploadInfo;
|
||||
expect((info[0] as any).fileInfo.width).toBe(320); // main video: real dims preserved
|
||||
expect((info[0] as any).fileInfo.height).toBe(240);
|
||||
expect((info[1] as any).fileInfo.width).toBe(720); // cover: fallback
|
||||
expect((info[1] as any).fileInfo.height).toBe(1280);
|
||||
});
|
||||
|
||||
it('main video carries the real `time` (duration in seconds) — regression: was 0', async () => {
|
||||
// NTV2 server bakes the `time` field into the resulting MsgInfo
|
||||
// bytes that ride along as `commonElem.pbElem`; the receiver
|
||||
// reads it back via `VideoFile.fileTime` and renders it as the
|
||||
// playable duration. NapCat ships `time: 0` only because it sits
|
||||
// on top of QQ-NT's IPC layer which patches the value in before
|
||||
// the wire send; we're a protocol-direct client (same position
|
||||
// as acidify) and must populate it ourselves. Without this the
|
||||
// receiver shows "00:00" on every video the bot sends.
|
||||
await uploadVideoMsgInfo({} as any, true, 12345, FINGERPRINT);
|
||||
const uploadInfo = vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0].uploadInfo;
|
||||
expect((uploadInfo[0] as any).fileInfo.time).toBe(10); // matches FINGERPRINT.duration
|
||||
expect((uploadInfo[1] as any).fileInfo.time).toBe(0); // thumb stays at 0 (matches acidify)
|
||||
});
|
||||
|
||||
it('thumb falls back to a synthesized 1x1 PNG with real bytes', async () => {
|
||||
await uploadVideoMsgInfo({} as any, true, 12345, FINGERPRINT);
|
||||
const uploads = vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0].uploads;
|
||||
expect(uploads[1]!.bytes.length).toBeGreaterThan(0); // FALLBACK_THUMB is non-empty
|
||||
});
|
||||
|
||||
it('main file uses per-1MB-block sha1 (an Uint8Array[]) — fingerprint path uses an empty array', async () => {
|
||||
await uploadVideoMsgInfo({} as any, true, 12345, FINGERPRINT);
|
||||
const uploads = vi.mocked(pipeline.runNtv2Upload).mock.calls[0]![0].uploads;
|
||||
// The fingerprint payload sets sha1Blocks: [] (no real bytes to chunk).
|
||||
expect(Array.isArray(uploads[0]!.sha1)).toBe(true);
|
||||
expect((uploads[0]!.sha1 as Uint8Array[]).length).toBe(0);
|
||||
});
|
||||
|
||||
it('fingerprint mode rejects when md5Hex or sha1Hex is missing', async () => {
|
||||
await expect(
|
||||
uploadVideoMsgInfo({} as any, true, 12345, { noByteFallback: true } as any),
|
||||
).rejects.toThrow(/requires md5Hex/);
|
||||
});
|
||||
|
||||
it('finalize is called without a defaultPic', async () => {
|
||||
await uploadVideoMsgInfo({} as any, true, 12345, FINGERPRINT);
|
||||
const args = vi.mocked(pipeline.finalizeMediaMsgInfo).mock.calls[0]!;
|
||||
expect(args[1]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('video-upload — streaming path (real on-disk source)', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(pipeline.runNtv2Upload).mockClear();
|
||||
});
|
||||
|
||||
it('stages + stream-hashes the source and PUTs via fileSource, never buffering bytes', async () => {
|
||||
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sl-vidstream-'));
|
||||
const file = path.join(dir, 'clip.mp4');
|
||||
const data = Buffer.alloc(3 * 1024 * 1024 + 77); // multi-1MiB-block + tail
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
data[i] = (i * 37 + 5) & 0xff;
|
||||
}
|
||||
|
||||
fs.writeFileSync(file, data);
|
||||
|
||||
try {
|
||||
const bridge = { identity: { uin: '10001' } } as any;
|
||||
await uploadVideoMsgInfo(bridge, true, 12345, {
|
||||
type: 'video',
|
||||
url: pathToFileURL(file).href,
|
||||
} as any);
|
||||
|
||||
const args = vi.mocked(pipeline.runNtv2Upload).mock.calls.at(-1)![0];
|
||||
const main = args.uploads[0]!;
|
||||
// Main file streams from disk — fileSource set, no in-memory bytes.
|
||||
expect(main.fileSource).toBeDefined();
|
||||
expect(main.fileSource!.fileSize).toBe(data.length);
|
||||
expect(main.bytes.length).toBe(0);
|
||||
// The OIDB fileInfo carries the STREAMED hashes/size — must equal the
|
||||
// buffered hash of the same bytes (byte-identical guarantee end-to-end).
|
||||
const fi = (args.uploadInfo[0] as any).fileInfo;
|
||||
expect(fi.fileSize).toBe(data.length);
|
||||
expect(fi.fileHash).toBe(computeHashes(data).md5Hex);
|
||||
expect(fi.fileSha1).toBe(computeHashes(data).sha1Hex);
|
||||
// The staged temp is a copy/hardlink under the stage dir (NOT the source)
|
||||
// and is cleaned up after the send.
|
||||
expect(main.fileSource!.filePath).not.toBe(file);
|
||||
expect(fs.existsSync(main.fileSource!.filePath)).toBe(false);
|
||||
} finally {
|
||||
await fsp.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('cleans up the staged temp even when the upload throws', async () => {
|
||||
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sl-vidfail-'));
|
||||
const file = path.join(dir, 'clip.mp4');
|
||||
fs.writeFileSync(file, Buffer.alloc(2048, 7));
|
||||
|
||||
try {
|
||||
vi.mocked(pipeline.runNtv2Upload).mockRejectedValueOnce(new Error('upload boom'));
|
||||
await expect(
|
||||
uploadVideoMsgInfo({ identity: { uin: '1' } } as any, true, 12345, {
|
||||
type: 'video',
|
||||
url: pathToFileURL(file).href,
|
||||
} as any),
|
||||
).rejects.toThrow(/upload boom/);
|
||||
// The staged temp created in loadVideo must be removed by the finally.
|
||||
const args = vi.mocked(pipeline.runNtv2Upload).mock.calls.at(-1)![0];
|
||||
const stagedPath = args.uploads[0]!.fileSource!.filePath;
|
||||
expect(fs.existsSync(stagedPath)).toBe(false);
|
||||
} finally {
|
||||
await fsp.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an empty video source (0 bytes)', async () => {
|
||||
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sl-vidempty-'));
|
||||
const file = path.join(dir, 'empty.mp4');
|
||||
fs.writeFileSync(file, new Uint8Array(0));
|
||||
|
||||
try {
|
||||
await expect(
|
||||
uploadVideoMsgInfo({ identity: { uin: '1' } } as any, true, 12345, {
|
||||
type: 'video',
|
||||
url: pathToFileURL(file).href,
|
||||
} as any),
|
||||
).rejects.toThrow(/empty/);
|
||||
} finally {
|
||||
await fsp.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user