1import { describe, expect, test } from "alchemy-test";
2import {
3 decode,
4 decodeAll,
5 encodeArray,
6 encodeBoolean,
7 encodeBulk,
8 encodeCommand,
9 encodeDouble,
10 encodeError,
11 encodeInteger,
12 encodeNull,
13 encodeNullArray,
14 encodeReply,
15 encodeSimpleString,
16 Parser,
17 type ParseResult,
18 type Reply,
19} from "@/Redis/Resp.ts";
20import { ProtocolError, ReplyError } from "@/Redis/Errors.ts";
21
22const utf8 = new TextEncoder();
23const bytes = (value: string): Uint8Array => utf8.encode(value);
24
25const asReply = (input: Uint8Array | string): Reply => {
26 const result = decode(input);
27 if (result._tag !== "Reply") {
28 throw new Error(`expected Reply, got ${JSON.stringify(result)}`);
29 }
30 return result.value;
31};
32
33const asError = (input: Uint8Array | string): ReplyError => {
34 const result = decode(input);
35 if (result._tag !== "Error") {
36 throw new Error(`expected Error, got ${JSON.stringify(result)}`);
37 }
38 return result.error;
39};
40
41const drain = (input: Uint8Array, size: number): ParseResult[] => {
42 const parser = new Parser();
43 const frames: ParseResult[] = [];
44 for (let offset = 0; offset < input.length; offset += size) {
45 parser.push(input.subarray(offset, offset + size));
46 while (true) {
47 const next = parser.next();
48 if (next._tag === "Incomplete") break;
49 frames.push(next);
50 if (next._tag === "Protocol") return frames;
51 }
52 }
53 return frames;
54};
55
56describe("encodeCommand", { tags: ["unit", "local"] }, () => {
57 test("PING is an array of bulk strings", () => {
58 expect(encodeCommand("PING")).toEqual(bytes("*1\r\n$4\r\nPING\r\n"));
59 });
60
61 test("LLEN mylist matches the RESP2 spec example", () => {
62 expect(encodeCommand("LLEN", ["mylist"])).toEqual(
63 bytes("*2\r\n$4\r\nLLEN\r\n$6\r\nmylist\r\n"),
64 );
65 });
66
67 test("bulk length is bytes, not JS string length", () => {
68 const encoded = encodeCommand("SET", ["k", "šŸ˜€"]);
69 expect(encoded).toEqual(
70 bytes("*3\r\n$3\r\nSET\r\n$1\r\nk\r\n$4\r\nšŸ˜€\r\n"),
71 );
72 });
73
74 test("binary args are length-prefixed without scanning", () => {
75 const payload = new Uint8Array([0, 13, 10, 36, 42]);
76 const encoded = encodeCommand("SET", ["bin", payload]);
77 const prefix = bytes("*3\r\n$3\r\nSET\r\n$3\r\nbin\r\n$5\r\n");
78 const expected = new Uint8Array(prefix.length + payload.length + 2);
79 expected.set(prefix, 0);
80 expected.set(payload, prefix.length);
81 expected.set([13, 10], prefix.length + payload.length);
82 expect(encoded).toEqual(expected);
83 });
84});
85
86describe("RESP2 decode", { tags: ["unit", "local"] }, () => {
87 test("simple string", () => {
88 expect(asReply("+OK\r\n")).toBe("OK");
89 expect(asReply("+\r\n")).toBe("");
90 });
91
92 test("error prefix and message", () => {
93 const error = asError("-ERR unknown command 'foobar'\r\n");
94 expect(error).toBeInstanceOf(ReplyError);
95 expect(error.code).toBe("ERR");
96 expect(error.message).toBe("ERR unknown command 'foobar'");
97 const wrongType = asError(
98 "-WRONGTYPE Operation against a key holding the wrong kind of value\r\n",
99 );
100 expect(wrongType.code).toBe("WRONGTYPE");
101 });
102
103 test("integers including negative", () => {
104 expect(asReply(":0\r\n")).toBe(0);
105 expect(asReply(":1000\r\n")).toBe(1000);
106 expect(asReply(":-1\r\n")).toBe(-1);
107 expect(asReply(":48293\r\n")).toBe(48293);
108 });
109
110 test("integers outside MAX_SAFE_INTEGER become bigint", () => {
111 expect(asReply(":9007199254740993\r\n")).toBe(9007199254740993n);
112 });
113
114 test("null bulk and empty bulk", () => {
115 expect(asReply("$-1\r\n")).toBeNull();
116 expect(asReply("$0\r\n\r\n")).toBe("");
117 });
118
119 test("bulk string with CR LF $ * inside", () => {
120 expect(asReply("$6\r\nfoobar\r\n")).toBe("foobar");
121 const payload = "\r\n$*";
122 expect(asReply(encodeBulk(payload))).toBe(payload);
123 });
124
125 test("null array, empty array, mixed array, nested array", () => {
126 expect(asReply("*-1\r\n")).toBeNull();
127 expect(asReply("*0\r\n")).toEqual([]);
128 expect(asReply("*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n")).toEqual(["foo", "bar"]);
129 expect(asReply("*3\r\n:1\r\n:2\r\n:3\r\n")).toEqual([1, 2, 3]);
130 expect(asReply("*5\r\n:1\r\n:2\r\n:3\r\n:4\r\n$6\r\nfoobar\r\n")).toEqual([
131 1,
132 2,
133 3,
134 4,
135 "foobar",
136 ]);
137 const nested = asReply(
138 "*2\r\n*3\r\n:1\r\n:2\r\n:3\r\n*2\r\n+Foo\r\n-Bar\r\n",
139 );
140 expect(Array.isArray(nested)).toBe(true);
141 const [first, second] = nested as Reply[];
142 expect(first).toEqual([1, 2, 3]);
143 expect(Array.isArray(second)).toBe(true);
144 const pair = second as Reply[];
145 expect(pair[0]).toBe("Foo");
146 expect(pair[1]).toBeInstanceOf(ReplyError);
147 expect((pair[1] as ReplyError).code).toBe("Bar");
148 });
149
150 test("null element in array", () => {
151 expect(asReply("*3\r\n$3\r\nfoo\r\n$-1\r\n$3\r\nbar\r\n")).toEqual([
152 "foo",
153 null,
154 "bar",
155 ]);
156 });
157
158 test("round-trip encodeReply", () => {
159 const value: Reply = ["ok", 3, null, ["nested"]];
160 expect(asReply(encodeReply(value))).toEqual(value);
161 });
162});
163
164describe("RESP3 decode", { tags: ["unit", "local"] }, () => {
165 test("null boolean double big number", () => {
166 expect(asReply("_\r\n")).toBeNull();
167 expect(asReply("#t\r\n")).toBe(true);
168 expect(asReply("#f\r\n")).toBe(false);
169 expect(asReply(",1.23\r\n")).toBe(1.23);
170 expect(asReply(",inf\r\n")).toBe(Infinity);
171 expect(asReply(",-inf\r\n")).toBe(-Infinity);
172 expect(Number.isNaN(asReply(",nan\r\n") as number)).toBe(true);
173 expect(asReply("(3492890328409238509324850943850943825024385\r\n")).toBe(
174 3492890328409238509324850943850943825024385n,
175 );
176 });
177
178 test("verbatim string strips the encoding prefix", () => {
179 expect(asReply("=15\r\ntxt:Some string\r\n")).toBe("Some string");
180 });
181
182 test("map with string keys becomes an object", () => {
183 expect(asReply("%2\r\n+first\r\n:1\r\n+second\r\n:2\r\n")).toEqual({
184 first: 1,
185 second: 2,
186 });
187 });
188
189 test("set is an array", () => {
190 expect(asReply("~2\r\n+orange\r\n+apple\r\n")).toEqual(["orange", "apple"]);
191 });
192
193 test("attributes are skipped and the following value is returned", () => {
194 expect(asReply("|1\r\n+ttl\r\n:3600\r\n:3\r\n")).toBe(3);
195 });
196
197 test("push frames are tagged", () => {
198 const result = decode(">2\r\n+message\r\n+chan\r\n");
199 expect(result).toEqual({
200 _tag: "Push",
201 value: ["message", "chan"],
202 });
203 });
204
205 test("blob error", () => {
206 const result = decode("!21\r\nSYNTAX invalid syntax\r\n");
207 expect(result._tag).toBe("Error");
208 if (result._tag === "Error") {
209 expect(result.error.code).toBe("SYNTAX");
210 expect(result.error.message).toBe("SYNTAX invalid syntax");
211 }
212 });
213
214 test("streamed string", () => {
215 expect(
216 asReply("$?\r\n;5\r\nHello\r\n;1\r\n \r\n;5\r\nworld\r\n;0\r\n"),
217 ).toBe("Hello world");
218 });
219
220 test("streamed array", () => {
221 expect(asReply("*?\r\n:1\r\n:2\r\n:3\r\n.\r\n")).toEqual([1, 2, 3]);
222 });
223});
224
225describe("incremental parser", { tags: ["unit", "local"] }, () => {
226 test("replies split one byte at a time", () => {
227 const payload = encodeArray([
228 encodeBulk("foo"),
229 encodeNull(),
230 encodeInteger(2),
231 encodeSimpleString("OK"),
232 ]);
233 const frames = drain(payload, 1);
234 expect(frames).toEqual([{ _tag: "Reply", value: ["foo", null, 2, "OK"] }]);
235 });
236
237 test("bulk payload split across chunks", () => {
238 const parser = new Parser();
239 parser.push(bytes("$6\r\nfoo"));
240 expect(parser.next()._tag).toBe("Incomplete");
241 parser.push(bytes("bar\r\n"));
242 expect(parser.next()).toEqual({ _tag: "Reply", value: "foobar" });
243 });
244
245 test("multiple replies in one buffer", () => {
246 const frames = decodeAll("+OK\r\n:1\r\n$4\r\nPONG\r\n");
247 expect(frames).toEqual([
248 { _tag: "Reply", value: "OK" },
249 { _tag: "Reply", value: 1 },
250 { _tag: "Reply", value: "PONG" },
251 ]);
252 });
253
254 test("CRLF split across chunks", () => {
255 const parser = new Parser();
256 parser.push(bytes("+OK\r"));
257 expect(parser.next()._tag).toBe("Incomplete");
258 parser.push(bytes("\n"));
259 expect(parser.next()).toEqual({ _tag: "Reply", value: "OK" });
260 });
261});
262
263describe("protocol errors", { tags: ["unit", "local"] }, () => {
264 test("unknown type byte", () => {
265 const result = decode("xOK\r\n");
266 expect(result._tag).toBe("Protocol");
267 if (result._tag === "Protocol") {
268 expect(result.error).toBeInstanceOf(ProtocolError);
269 }
270 });
271
272 test("incomplete value", () => {
273 const result = decode("$6\r\nfoo");
274 expect(result._tag).toBe("Protocol");
275 });
276
277 test("encode helpers used by servers", () => {
278 expect(asReply(encodeBoolean(true))).toBe(true);
279 expect(asReply(encodeDouble(1.5))).toBe(1.5);
280 expect(asError(encodeError("ERR boom")).code).toBe("ERR");
281 expect(asReply(encodeNull())).toBeNull();
282 expect(asReply(encodeNullArray())).toBeNull();
283 });
284});