Files
bun.sh/test/js/valkey/integration/complex-operations.test.ts

472 lines
16 KiB
TypeScript

import { randomUUIDv7 } from "bun";
import { beforeEach, describe, expect, test } from "bun:test";
import { ConnectionType, createClient, ctx, isEnabled } from "../test-utils";
/**
* Integration test suite for complex Redis operations
* - Transaction handling
* - Pipelining (to be implemented)
* - Complex data type operations
* - Realistic use cases
*/
describe.skipIf(!isEnabled)("Valkey: Complex Operations", () => {
beforeEach(() => {
if (ctx.redis?.connected) {
ctx.redis.close?.();
}
ctx.redis = createClient(ConnectionType.TCP);
});
describe("Multi/Exec Transactions", () => {
test("should execute commands in a transaction", async () => {
const prefix = ctx.generateKey("transaction");
const key1 = `${prefix}-1`;
const key2 = `${prefix}-2`;
const key3 = `${prefix}-3`;
// Start transaction
await ctx.redis.send("MULTI", []);
// Queue commands in transaction
const queueResults = await Promise.all([
ctx.redis.set(key1, "value1"),
ctx.redis.set(key2, "value2"),
ctx.redis.incr(key3),
ctx.redis.get(key1),
]);
// All queue commands should return "QUEUED"
for (const result of queueResults) {
expect(result).toBe("QUEUED");
}
// Execute transaction
const execResult = await ctx.redis.send("EXEC", []);
// Should get an array of results
expect(Array.isArray(execResult)).toBe(true);
expect(execResult.length).toBe(4);
// Check individual results
expect(execResult[0]).toBe("OK"); // SET result
expect(execResult[1]).toBe("OK"); // SET result
expect(execResult[2]).toBe(1); // INCR result
expect(execResult[3]).toBe("value1"); // GET result
// Verify the transaction was applied
const key1Value = await ctx.redis.get(key1);
expect(key1Value).toBe("value1");
const key2Value = await ctx.redis.get(key2);
expect(key2Value).toBe("value2");
const key3Value = await ctx.redis.get(key3);
expect(key3Value).toBe("1");
});
test("should handle transaction discards", async () => {
const prefix = ctx.generateKey("transaction-discard");
const key = `${prefix}-key`;
// Set initial value
await ctx.redis.set(key, "initial");
// Start transaction
await ctx.redis.send("MULTI", []);
// Queue some commands
await ctx.redis.set(key, "changed");
await ctx.redis.incr(`${prefix}-counter`);
// Discard the transaction
const discardResult = await ctx.redis.send("DISCARD", []);
expect(discardResult).toBe("OK");
// Verify the key was not changed
const value = await ctx.redis.get(key);
expect(value).toBe("initial");
// Verify counter was not incremented
const counterValue = await ctx.redis.get(`${prefix}-counter`);
expect(counterValue).toBeNull();
});
test("should handle transaction errors", async () => {
const prefix = ctx.generateKey("transaction-error");
const key = `${prefix}-key`;
// Set initial value
await ctx.redis.set(key, "string-value");
// Start transaction
await ctx.redis.send("MULTI", []);
// Queue valid command
await ctx.redis.set(`${prefix}-valid`, "valid");
// Queue command that will fail (INCR on a string)
await ctx.redis.incr(key);
// Queue another valid command
await ctx.redis.set(`${prefix}-after`, "after");
// Execute transaction
const execResult = await ctx.redis.send("EXEC", []);
// Should get an array of results, with error for the failing command
expect(Array.isArray(execResult)).toBe(true);
expect(execResult).toMatchInlineSnapshot(`
[
"OK",
[Error: ERR value is not an integer or out of range],
"OK",
]
`);
// Verify the valid commands were executed
const validValue = await ctx.redis.get(`${prefix}-valid`);
expect(validValue).toBe("valid");
const afterValue = await ctx.redis.get(`${prefix}-after`);
expect(afterValue).toBe("after");
});
test("should handle nested commands in transaction", async () => {
const prefix = ctx.generateKey("transaction-nested");
const hashKey = `${prefix}-hash`;
const setKey = `${prefix}-set`;
// Start transaction
await ctx.redis.send("MULTI", []);
// Queue complex data type commands
await ctx.redis.send("HSET", [hashKey, "field1", "value1", "field2", "value2"]);
await ctx.redis.send("SADD", [setKey, "member1", "member2", "member3"]);
await ctx.redis.send("HGETALL", [hashKey]);
await ctx.redis.send("SMEMBERS", [setKey]);
// Execute transaction
const execResult = await ctx.redis.send("EXEC", []);
// Should get an array of results
expect(Array.isArray(execResult)).toBe(true);
expect(execResult.length).toBe(4);
// HSET should return number of fields added
expect(execResult[0]).toBe(2);
// SADD should return number of members added
expect(execResult[1]).toBe(3);
// HGETALL should return hash object or array
const hashResult = execResult[2];
if (typeof hashResult === "object" && hashResult !== null) {
// RESP3 style (map)
expect(hashResult.field1).toBe("value1");
expect(hashResult.field2).toBe("value2");
} else if (Array.isArray(hashResult)) {
// RESP2 style (array of field-value pairs)
expect(hashResult.length).toBe(4);
expect(hashResult).toContain("field1");
expect(hashResult).toContain("value1");
expect(hashResult).toContain("field2");
expect(hashResult).toContain("value2");
}
// SMEMBERS should return array
expect(Array.isArray(execResult[3])).toBe(true);
expect(execResult[3].length).toBe(3);
expect(execResult[3]).toContain("member1");
expect(execResult[3]).toContain("member2");
expect(execResult[3]).toContain("member3");
});
});
describe("Complex Key Patterns", () => {
test("should handle hierarchical key patterns", async () => {
// Create hierarchical key structure (user:id:field)
const userId = randomUUIDv7().substring(0, 8);
const baseKey = ctx.generateKey(`user:${userId}`);
// Set multiple fields
await ctx.redis.set(`${baseKey}:name`, "John Doe");
await ctx.redis.set(`${baseKey}:email`, "john@example.com");
await ctx.redis.set(`${baseKey}:age`, "30");
// Create a counter
await ctx.redis.incr(`${baseKey}:visits`);
await ctx.redis.incr(`${baseKey}:visits`);
// Get all keys matching the pattern
const patternResult = await ctx.redis.send("KEYS", [`${baseKey}:*`]);
// Should find all our keys
expect(Array.isArray(patternResult)).toBe(true);
expect(patternResult.length).toBe(4);
// Sort for consistent snapshot
const sortedKeys = [...patternResult].sort();
expect(sortedKeys).toMatchInlineSnapshot(`
[
"${baseKey}:age",
"${baseKey}:email",
"${baseKey}:name",
"${baseKey}:visits",
]
`);
// Verify values
const nameValue = await ctx.redis.get(`${baseKey}:name`);
expect(nameValue).toBe("John Doe");
const visitsValue = await ctx.redis.get(`${baseKey}:visits`);
expect(visitsValue).toBe("2");
});
test("should handle complex key patterns with expiry", async () => {
// Create session-like structure
const sessionId = randomUUIDv7().substring(0, 8);
const baseKey = ctx.generateKey(`session:${sessionId}`);
// Set session data with expiry
await ctx.redis.set(`${baseKey}:data`, JSON.stringify({ user: "user123", role: "admin" }));
await ctx.redis.expire(`${baseKey}:data`, 30); // 30 second expiry
// Set session heartbeat with shorter expiry
await ctx.redis.set(`${baseKey}:heartbeat`, Date.now().toString());
await ctx.redis.expire(`${baseKey}:heartbeat`, 10); // 10 second expiry
// Verify TTLs
const dataTtl = await ctx.redis.ttl(`${baseKey}:data`);
expect(typeof dataTtl).toBe("number");
expect(dataTtl).toBeGreaterThan(0);
expect(dataTtl).toBeLessThanOrEqual(30);
const heartbeatTtl = await ctx.redis.ttl(`${baseKey}:heartbeat`);
expect(typeof heartbeatTtl).toBe("number");
expect(heartbeatTtl).toBeGreaterThan(0);
expect(heartbeatTtl).toBeLessThanOrEqual(10);
// Update heartbeat and reset TTL
await ctx.redis.set(`${baseKey}:heartbeat`, Date.now().toString());
await ctx.redis.expire(`${baseKey}:heartbeat`, 10);
// Verify updated TTL
const updatedTtl = await ctx.redis.ttl(`${baseKey}:heartbeat`);
expect(updatedTtl).toBeGreaterThan(0);
expect(updatedTtl).toBeLessThanOrEqual(10);
});
});
describe("Realistic Use Cases", () => {
test("should implement a simple rate limiter", async () => {
// Implementation of a rate limiter using Redis
const ipAddress = "192.168.1.1";
const rateLimitKey = ctx.generateKey(`ratelimit:${ipAddress}`);
const maxRequests = 5;
const windowSecs = 10;
// Function to check if the IP is rate limited
async function isRateLimited() {
// Get current count
const count = await ctx.redis.incr(rateLimitKey);
// If this is the first request, set expiry
if (count === 1) {
await ctx.redis.expire(rateLimitKey, windowSecs);
}
// Check if over limit
return count > maxRequests;
}
// Simulate multiple requests
const results = [];
for (let i = 0; i < 7; i++) {
results.push(await isRateLimited());
}
// Check results with inline snapshot for better readability
expect(results).toMatchInlineSnapshot(`
[
false,
false,
false,
false,
false,
true,
true,
]
`);
const finalCount = await ctx.redis.get(rateLimitKey);
expect(finalCount).toBe("7");
// Verify TTL exists
const ttl = await ctx.redis.ttl(rateLimitKey);
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThanOrEqual(windowSecs);
});
test("should implement a simple cache with expiry", async () => {
const cachePrefix = ctx.generateKey("cache");
// Cache implementation
async function getOrSetCache(key, ttl, fetchFunction) {
const cacheKey = `${cachePrefix}:${key}`;
// Try to get from cache
const cachedValue = await ctx.redis.get(cacheKey);
if (cachedValue !== null) {
return JSON.parse(cachedValue);
}
// Not in cache, fetch the value
const freshValue = await fetchFunction();
// Store in cache with expiry
await ctx.redis.set(cacheKey, JSON.stringify(freshValue));
await ctx.redis.expire(cacheKey, ttl);
return freshValue;
}
// Simulate expensive operation
let fetchCount = 0;
async function fetchData() {
fetchCount++;
return { data: "example", timestamp: Date.now() };
}
// First fetch should call the function
const result1 = await getOrSetCache("test-key", 30, fetchData);
expect(result1).toBeDefined();
expect(fetchCount).toBe(1);
// Second fetch should use cache
const result2 = await getOrSetCache("test-key", 30, fetchData);
expect(result2).toBeDefined();
expect(fetchCount).toBe(1); // Still 1 because we used cache
// Different key should call function again
const result3 = await getOrSetCache("other-key", 30, fetchData);
expect(result3).toBeDefined();
expect(fetchCount).toBe(2);
// Verify cache entry has TTL
const ttl = await ctx.redis.ttl(`${cachePrefix}:test-key`);
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThanOrEqual(30);
});
test("should implement a simple leaderboard", async () => {
const leaderboardKey = ctx.generateKey("leaderboard");
// Add scores
await ctx.redis.send("ZADD", [leaderboardKey, "100", "player1"]);
await ctx.redis.send("ZADD", [leaderboardKey, "200", "player2"]);
await ctx.redis.send("ZADD", [leaderboardKey, "150", "player3"]);
await ctx.redis.send("ZADD", [leaderboardKey, "300", "player4"]);
await ctx.redis.send("ZADD", [leaderboardKey, "50", "player5"]);
// Get top 3 players (highest scores)
const topPlayers = await ctx.redis.send("ZREVRANGE", [leaderboardKey, "0", "2", "WITHSCORES"]);
expect(topPlayers).toMatchInlineSnapshot(`
[
[
"player4",
300,
],
[
"player2",
200,
],
[
"player3",
150,
],
]
`);
// Get player rank (0-based)
const player3Rank = await ctx.redis.send("ZREVRANK", [leaderboardKey, "player3"]);
expect(player3Rank).toBe(2); // 0-based index, so 3rd place is 2
// Get player score
const player3Score = await ctx.redis.send("ZSCORE", [leaderboardKey, "player3"]);
expect(player3Score).toBe(150);
// Increment a score
await ctx.redis.send("ZINCRBY", [leaderboardKey, "25", "player3"]);
// Verify score was updated
const updatedScore = await ctx.redis.send("ZSCORE", [leaderboardKey, "player3"]);
expect(updatedScore).toBe(175);
// Get updated rank
const updatedRank = await ctx.redis.send("ZREVRANK", [leaderboardKey, "player3"]);
expect(updatedRank).toBe(2); // Still in third place
// Get count of players with scores between 100 and 200
const countInRange = await ctx.redis.send("ZCOUNT", [leaderboardKey, "100", "200"]);
expect(countInRange).toBe(3); // player1, player3, player2
});
});
describe("Distributed Locks", () => {
test("should implement a simple distributed lock", async () => {
const lockName = ctx.generateKey("lock-resource");
const lockValue = randomUUIDv7(); // Unique identifier for the owner
const lockTimeout = 10; // seconds
// Acquire the lock
const acquireResult = await ctx.redis.send("SET", [
lockName,
lockValue,
"NX", // Only set if key doesn't exist
"EX",
lockTimeout.toString(),
]);
// Should acquire the lock successfully
expect(acquireResult).toBe("OK");
// Try to acquire again (should fail as it's already locked)
const retryResult = await ctx.redis.send("SET", [lockName, "other-value", "NX", "EX", lockTimeout.toString()]);
// Should return null (lock not acquired)
expect(retryResult).toBeNull();
// LUA script for safe release (only release if we own the lock)
const releaseLockScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
// Release the lock
const releaseResult = await ctx.redis.send("EVAL", [
releaseLockScript,
"1", // Number of keys
lockName, // KEYS[1]
lockValue, // ARGV[1]
]);
// Should return 1 (lock released)
expect(releaseResult).toBe(1);
// Try to release again (should fail as lock is gone)
const reReleaseResult = await ctx.redis.send("EVAL", [releaseLockScript, "1", lockName, lockValue]);
// Should return 0 (no lock to release)
expect(reReleaseResult).toBe(0);
// Verify lock is gone
const finalCheck = await ctx.redis.get(lockName);
expect(finalCheck).toBeNull();
});
});
});