Files
bun.sh/test/js/valkey/reliability/protocol-handling.test.ts
2025-04-08 19:59:08 -07:00

402 lines
13 KiB
TypeScript

import { beforeEach, describe, expect, test } from "bun:test";
import { ConnectionType, createClient, ctx, isEnabled, testKey } from "../test-utils";
/**
* Test suite for RESP protocol handling, focusing on edge cases
* - RESP3 data types handling
* - Protocol parsing edge cases
* - Bulk string/array handling
* - Special value encoding/decoding
*/
describe.skipIf(!isEnabled)("Valkey: Protocol Handling", () => {
beforeEach(() => {
if (ctx.redis?.connected) {
ctx.redis.close?.();
}
ctx.redis = createClient(ConnectionType.TCP);
});
describe("RESP3 Data Type Handling", () => {
test("should handle RESP3 Map type (HGETALL)", async () => {
// Create a hash with multiple fields
const hashKey = testKey("map-test");
await ctx.redis.send("HSET", [
hashKey,
"field1",
"value1",
"field2",
"value2",
"number",
"42",
"empty",
"",
"special",
"hello\r\nworld",
]);
// Get as RESP3 Map via HGETALL
const mapResult = await ctx.redis.send("HGETALL", [hashKey]);
expect(mapResult).toMatchInlineSnapshot(`
{
"empty": "",
"field1": "value1",
"field2": "value2",
"number": "42",
"special":
"hello
world"
,
}
`);
});
test("should handle RESP3 Set type", async () => {
// Create a set with multiple members
const setKey = testKey("set-test");
await ctx.redis.send("SADD", [setKey, "member1", "member2", "42", "", "special \r\n character"]);
// Get as RESP3 Set via SMEMBERS
const setResult = await ctx.redis.send("SMEMBERS", [setKey]);
expect(JSON.stringify(setResult)).toMatchInlineSnapshot(
`"["member1","member2","42","","special \\r\\n character"]"`,
);
});
test("should handle RESP3 Boolean type", async () => {
const key = testKey("bool-test");
await ctx.redis.set(key, "value");
// EXISTS returns Boolean in RESP3
const existsResult = await ctx.redis.exists(key);
expect(typeof existsResult).toBe("boolean");
expect(existsResult).toBe(true);
// Non-existent key
const notExistsResult = await ctx.redis.exists(testKey("nonexistent"));
expect(typeof notExistsResult).toBe("boolean");
expect(notExistsResult).toBe(false);
});
test("should handle RESP3 Number types", async () => {
const counterKey = testKey("counter");
// Various numeric commands to test number handling
// INCR returns integer
const incrResult = await ctx.redis.incr(counterKey);
expect(typeof incrResult).toBe("number");
expect(incrResult).toBe(1);
// Use INCRBYFLOAT to test double/float handling
const doubleResult = await ctx.redis.send("INCRBYFLOAT", [counterKey, "1.5"]);
// Some Redis versions return this as string, either is fine
expect(doubleResult === "2.5" || doubleResult === 2.5).toBe(true);
// Use HINCRBYFLOAT to test another float command
const hashKey = testKey("hash-float");
const hashDoubleResult = await ctx.redis.send("HINCRBYFLOAT", [hashKey, "field", "10.5"]);
// Should be string or number
expect(hashDoubleResult === "10.5" || hashDoubleResult === 10.5).toBe(true);
});
test("should handle RESP3 Null type", async () => {
// GET non-existent key
const nullResult = await ctx.redis.get(testKey("nonexistent"));
expect(nullResult).toBeNull();
// HGET non-existent field
const hashKey = testKey("hash");
await ctx.redis.send("HSET", [hashKey, "existing", "value"]);
const nullFieldResult = await ctx.redis.send("HGET", [hashKey, "nonexistent"]);
expect(nullFieldResult).toBeNull();
// BLPOP with timeout
const listKey = testKey("empty-list");
const timeoutResult = await ctx.redis.send("BLPOP", [listKey, "1"]);
expect(timeoutResult).toBeNull();
});
test("should handle RESP3 Error type", async () => {
expect(async () => await ctx.redis.send("GET", [])).toThrowErrorMatchingInlineSnapshot(
`"ERR wrong number of arguments for 'get' command"`,
);
expect(async () => await ctx.redis.send("SYNTAX-ERROR", [])).toThrowErrorMatchingInlineSnapshot(
`"ERR unknown command 'SYNTAX-ERROR', with args beginning with: "`,
);
});
});
describe("Protocol Parsing Edge Cases", () => {
test("should handle nested array structures", async () => {
// XRANGE returns array of arrays
const streamKey = testKey("stream");
// Add entries to stream
await ctx.redis.send("XADD", [streamKey, "*", "field1", "value1", "field2", "value2"]);
await ctx.redis.send("XADD", [streamKey, "*", "field1", "value3", "field2", "value4"]);
// Get range
const rangeResult = await ctx.redis.send("XRANGE", [streamKey, "-", "+"]);
// Should get array of arrays
expect(Array.isArray(rangeResult)).toBe(true);
expect(rangeResult.length).toBe(2);
// First entry should have ID and fields
const firstEntry = rangeResult[0];
expect(Array.isArray(firstEntry)).toBe(true);
expect(firstEntry.length).toBe(2);
// ID should be string
expect(typeof firstEntry[0]).toBe("string");
// Fields should be array of field-value pairs in RESP2 or object in RESP3
const fields = firstEntry[1];
if (Array.isArray(fields)) {
// RESP2 style
expect(fields.length % 2).toBe(0); // Even number for field-value pairs
} else if (typeof fields === "object" && fields !== null) {
// RESP3 style (map)
expect(fields.field1).toBeTruthy();
expect(fields.field2).toBeTruthy();
}
});
test("should handle empty strings in bulk strings", async () => {
// Set empty string value
const key = testKey("empty-string");
await ctx.redis.set(key, "");
// Get it back
const result = await ctx.redis.get(key);
expect(result).toBe("");
// HSET with empty field and/or value
const hashKey = testKey("hash-empty");
await ctx.redis.send("HSET", [hashKey, "empty-field", ""]);
await ctx.redis.send("HSET", [hashKey, "", "empty-field-name"]);
// Get them back
const emptyValue = await ctx.redis.send("HGET", [hashKey, "empty-field"]);
expect(emptyValue).toBe("");
const emptyField = await ctx.redis.send("HGET", [hashKey, ""]);
expect(emptyField).toBe("empty-field-name");
});
test("should handle large arrays", async () => {
// Create a large set
const setKey = testKey("large-set");
const itemCount = 10000;
// Add many members (in chunks to avoid huge command)
for (let i = 0; i < itemCount; i += 100) {
const members = [];
for (let j = 0; j < 100 && i + j < itemCount; j++) {
members.push(`member-${i + j}`);
}
await ctx.redis.send("SADD", [setKey, ...members]);
}
// Get all members
const membersResult = await ctx.redis.send("SMEMBERS", [setKey]);
// Should get all members back
expect(Array.isArray(membersResult)).toBe(true);
expect(membersResult.length).toBe(itemCount);
// Check a few random members
for (let i = 0; i < 10; i++) {
const index = Math.floor(Math.random() * itemCount);
expect(membersResult).toContain(`member-${index}`);
}
});
test("should handle very large bulk strings", async () => {
// Create various sized strings
const sizes = [
1024, // 1KB
10 * 1024, // 10KB
100 * 1024, // 100KB
1024 * 1024, // 1MB
];
for (const size of sizes) {
const key = testKey(`large-string-${size}`);
const value = Buffer.alloc(size, "x").toString();
// Set the large value
await ctx.redis.set(key, value);
// Get it back
const result = await ctx.redis.get(key);
// Should be same length
expect(result?.length).toBe(size);
expect(result).toBe(value);
}
});
test("should handle binary data", async () => {
// Create binary data with full byte range
const binaryData = new Uint8Array(256);
for (let i = 0; i < 256; i++) {
binaryData[i] = i;
}
// Convert to string for Redis storage
const binaryString = String.fromCharCode(...binaryData);
// Store binary data
const key = testKey("binary-data");
await ctx.redis.set(key, binaryString);
// Get it back
const result = await ctx.redis.get(key);
// Should have same length
expect(result?.length).toBe(binaryString.length);
// Verify each byte
if (result) {
for (let i = 0; i < Math.min(256, result.length); i++) {
expect(result.charCodeAt(i)).toBe(i);
}
}
});
});
describe("Special Value Handling", () => {
test("should handle RESP protocol delimiter characters", async () => {
try {
// Set values containing RESP delimiters
const testCases = [
{ key: testKey("cr"), value: "contains\rcarriage\rreturn" },
{ key: testKey("lf"), value: "contains\nline\nfeed" },
{ key: testKey("crlf"), value: "contains\r\ncrlf\r\ndelimiters" },
{ key: testKey("mixed"), value: "mixed\r\n\r\n\r\ndelimiters" },
];
for (const { key, value } of testCases) {
await ctx.redis.set(key, value);
const result = await ctx.redis.get(key);
expect(result).toBe(value);
}
} catch (error) {
console.warn("RESP delimiter test failed:", error.message);
throw error;
}
});
test("should handle special RESP types in data", async () => {
const client = ctx.redis;
try {
// Values that might confuse RESP parser if not properly handled
const testCases = [
{ key: testKey("plus"), value: "+OK\r\n" }, // Simple string prefix
{ key: testKey("minus"), value: "-ERR\r\n" }, // Error prefix
{ key: testKey("colon"), value: ":123\r\n" }, // Integer prefix
{ key: testKey("dollar"), value: "$5\r\nhello\r\n" }, // Bulk string format
{ key: testKey("asterisk"), value: "*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n" }, // Array format
];
for (const { key, value } of testCases) {
await client.set(key, value);
const result = await client.get(key);
expect(result).toBe(value);
}
} catch (error) {
console.warn("RESP types in data test failed:", error.message);
throw error;
}
});
});
describe.todo("RESP3 Push Type Handling", () => {});
describe("Extreme Protocol Conditions", () => {
test("should handle rapidly switching between command types", async () => {
const client = ctx.redis;
try {
// Rapidly alternate between different command types
// to stress protocol parser context switching
const iterations = 100;
const prefix = testKey("rapid");
for (let i = 0; i < iterations; i++) {
// String operations
await client.set(`${prefix}-str-${i}`, `value-${i}`);
await client.get(`${prefix}-str-${i}`);
// Integer operations
await client.incr(`${prefix}-int-${i}`);
// Array result operations
await client.send("KEYS", [`${prefix}-str-${i}`]);
// Hash operations (map responses)
await client.send("HSET", [`${prefix}-hash-${i}`, "field", "value"]);
await client.send("HGETALL", [`${prefix}-hash-${i}`]);
// Set operations
await client.send("SADD", [`${prefix}-set-${i}`, "member1", "member2"]);
await client.send("SMEMBERS", [`${prefix}-set-${i}`]);
}
// If we got here without protocol parse errors, test passes
} catch (error) {
console.warn("Rapid command switching test failed:", error.message);
throw error;
}
});
test("should handle simultaneous command streams", async () => {
// Create multiple clients for parallel operations
const clientCount = 5;
const clients = Array.from({ length: clientCount }, () => createClient());
try {
// Run many operations in parallel across clients
const operationsPerClient = 20;
const prefix = testKey("parallel");
const allPromises = clients.flatMap((client, clientIndex) => {
const promises = [];
for (let i = 0; i < operationsPerClient; i++) {
const key = `${prefix}-c${clientIndex}-${i}`;
// Mix of operation types
promises.push(client.set(key, `value-${i}`));
promises.push(client.get(key));
promises.push(client.incr(`${key}-counter`));
promises.push(client.send("HSET", [`${key}-hash`, "field", "value"]));
}
return promises;
});
// Run all operations simultaneously
await Promise.all(allPromises);
// If we got here without errors, test passes
} catch (error) {
console.warn("Parallel client test failed:", error.message);
throw error;
} finally {
// Clean up clients
await Promise.all(clients.map(client => client.close()));
}
});
});
});