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

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
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();
});
});
+294
View File
@@ -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 });
}
});
});