|
| 1 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 2 | + |
| 3 | +import { |
| 4 | + CookieOptions, |
| 5 | + deleteChunkedCookie, |
| 6 | + getChunkedCookie, |
| 7 | + RequestCookies, |
| 8 | + ResponseCookies, |
| 9 | + setChunkedCookie |
| 10 | +} from "./cookies"; |
| 11 | + |
| 12 | +// Create mock implementation for RequestCookies and ResponseCookies |
| 13 | +const createMocks = () => { |
| 14 | + const cookieStore = new Map(); |
| 15 | + |
| 16 | + const reqCookies = { |
| 17 | + get: vi.fn((...args) => { |
| 18 | + const name = typeof args[0] === "string" ? args[0] : args[0].name; |
| 19 | + if (cookieStore.has(name)) { |
| 20 | + return { name, value: cookieStore.get(name) }; |
| 21 | + } |
| 22 | + return undefined; |
| 23 | + }), |
| 24 | + getAll: vi.fn((...args) => { |
| 25 | + if (args.length === 0) { |
| 26 | + return Array.from(cookieStore.entries()).map(([name, value]) => ({ |
| 27 | + name, |
| 28 | + value |
| 29 | + })); |
| 30 | + } |
| 31 | + const name = typeof args[0] === "string" ? args[0] : args[0].name; |
| 32 | + return cookieStore.has(name) |
| 33 | + ? [{ name, value: cookieStore.get(name) }] |
| 34 | + : []; |
| 35 | + }), |
| 36 | + has: vi.fn((name) => cookieStore.has(name)), |
| 37 | + set: vi.fn((...args) => { |
| 38 | + const name = typeof args[0] === "string" ? args[0] : args[0].name; |
| 39 | + const value = typeof args[0] === "string" ? args[1] : args[0].value; |
| 40 | + cookieStore.set(name, value); |
| 41 | + return reqCookies; |
| 42 | + }), |
| 43 | + delete: vi.fn((names) => { |
| 44 | + if (Array.isArray(names)) { |
| 45 | + return names.map((name) => cookieStore.delete(name)); |
| 46 | + } |
| 47 | + return cookieStore.delete(names); |
| 48 | + }), |
| 49 | + clear: vi.fn(() => { |
| 50 | + cookieStore.clear(); |
| 51 | + return reqCookies; |
| 52 | + }), |
| 53 | + get size() { |
| 54 | + return cookieStore.size; |
| 55 | + }, |
| 56 | + [Symbol.iterator]: vi.fn(() => cookieStore.entries()) |
| 57 | + }; |
| 58 | + |
| 59 | + const resCookies = { |
| 60 | + get: vi.fn((...args) => { |
| 61 | + const name = typeof args[0] === "string" ? args[0] : args[0].name; |
| 62 | + if (cookieStore.has(name)) { |
| 63 | + return { name, value: cookieStore.get(name) }; |
| 64 | + } |
| 65 | + return undefined; |
| 66 | + }), |
| 67 | + getAll: vi.fn((...args) => { |
| 68 | + if (args.length === 0) { |
| 69 | + return Array.from(cookieStore.entries()).map(([name, value]) => ({ |
| 70 | + name, |
| 71 | + value |
| 72 | + })); |
| 73 | + } |
| 74 | + const name = typeof args[0] === "string" ? args[0] : args[0].name; |
| 75 | + return cookieStore.has(name) |
| 76 | + ? [{ name, value: cookieStore.get(name) }] |
| 77 | + : []; |
| 78 | + }), |
| 79 | + has: vi.fn((name) => cookieStore.has(name)), |
| 80 | + set: vi.fn((...args) => { |
| 81 | + const name = typeof args[0] === "string" ? args[0] : args[0].name; |
| 82 | + const value = typeof args[0] === "string" ? args[1] : args[0].value; |
| 83 | + cookieStore.set(name, value); |
| 84 | + return resCookies; |
| 85 | + }), |
| 86 | + delete: vi.fn((...args) => { |
| 87 | + const name = typeof args[0] === "string" ? args[0] : args[0].name; |
| 88 | + cookieStore.delete(name); |
| 89 | + return resCookies; |
| 90 | + }), |
| 91 | + toString: vi.fn(() => { |
| 92 | + return Array.from(cookieStore.entries()) |
| 93 | + .map(([name, value]) => `${name}=${value}`) |
| 94 | + .join("; "); |
| 95 | + }) |
| 96 | + }; |
| 97 | + |
| 98 | + return { reqCookies, resCookies, cookieStore }; |
| 99 | +}; |
| 100 | + |
| 101 | +describe("Chunked Cookie Utils", () => { |
| 102 | + let reqCookies: RequestCookies; |
| 103 | + let resCookies: ResponseCookies; |
| 104 | + let cookieStore: Map<any, any>; |
| 105 | + |
| 106 | + beforeEach(() => { |
| 107 | + const mocks = createMocks(); |
| 108 | + reqCookies = mocks.reqCookies; |
| 109 | + resCookies = mocks.resCookies; |
| 110 | + cookieStore = mocks.cookieStore; |
| 111 | + |
| 112 | + // Spy on console.warn |
| 113 | + vi.spyOn(console, "warn").mockImplementation(() => {}); |
| 114 | + }); |
| 115 | + |
| 116 | + afterEach(() => { |
| 117 | + vi.clearAllMocks(); |
| 118 | + }); |
| 119 | + |
| 120 | + describe("setChunkedCookie", () => { |
| 121 | + it("should set a single cookie when value is small enough", () => { |
| 122 | + const name = "testCookie"; |
| 123 | + const value = "small value"; |
| 124 | + const options = { path: "/" } as CookieOptions; |
| 125 | + |
| 126 | + setChunkedCookie(name, value, options, reqCookies, resCookies); |
| 127 | + |
| 128 | + expect(resCookies.set).toHaveBeenCalledTimes(1); |
| 129 | + expect(resCookies.set).toHaveBeenCalledWith(name, value, options); |
| 130 | + expect(reqCookies.set).toHaveBeenCalledTimes(1); |
| 131 | + expect(reqCookies.set).toHaveBeenCalledWith(name, value); |
| 132 | + }); |
| 133 | + |
| 134 | + it("should split cookie into chunks when value exceeds max size", () => { |
| 135 | + const name = "largeCookie"; |
| 136 | + const options = { path: "/" } as CookieOptions; |
| 137 | + |
| 138 | + // Create a large string (8000 bytes) |
| 139 | + const largeValue = "a".repeat(8000); |
| 140 | + |
| 141 | + setChunkedCookie(name, largeValue, options, reqCookies, resCookies); |
| 142 | + |
| 143 | + // Should create 3 chunks (8000 / 3500 ≈ 2.3, rounded up to 3) |
| 144 | + expect(resCookies.set).toHaveBeenCalledTimes(3); |
| 145 | + expect(reqCookies.set).toHaveBeenCalledTimes(3); |
| 146 | + |
| 147 | + // Check first chunk |
| 148 | + expect(resCookies.set).toHaveBeenCalledWith( |
| 149 | + `${name}__0`, |
| 150 | + largeValue.slice(0, 3500), |
| 151 | + options |
| 152 | + ); |
| 153 | + |
| 154 | + // Check second chunk |
| 155 | + expect(resCookies.set).toHaveBeenCalledWith( |
| 156 | + `${name}__1`, |
| 157 | + largeValue.slice(3500, 7000), |
| 158 | + options |
| 159 | + ); |
| 160 | + |
| 161 | + // Check third chunk |
| 162 | + expect(resCookies.set).toHaveBeenCalledWith( |
| 163 | + `${name}__2`, |
| 164 | + largeValue.slice(7000), |
| 165 | + options |
| 166 | + ); |
| 167 | + }); |
| 168 | + |
| 169 | + it("should log a warning when cookie size exceeds warning threshold", () => { |
| 170 | + const name = "warningCookie"; |
| 171 | + const options = { path: "/" } as CookieOptions; |
| 172 | + |
| 173 | + // Create a value that exceeds the warning threshold (4096 bytes) |
| 174 | + const value = "a".repeat(4097); |
| 175 | + |
| 176 | + setChunkedCookie(name, value, options, reqCookies, resCookies); |
| 177 | + |
| 178 | + expect(console.warn).toHaveBeenCalled(); |
| 179 | + }); |
| 180 | + |
| 181 | + describe("getChunkedCookie", () => { |
| 182 | + it("should return undefined when cookie does not exist", () => { |
| 183 | + const result = getChunkedCookie("nonexistent", reqCookies); |
| 184 | + expect(result).toBeUndefined(); |
| 185 | + }); |
| 186 | + |
| 187 | + it("should return cookie value when it exists as a regular cookie", () => { |
| 188 | + const name = "simpleCookie"; |
| 189 | + const value = "simple value"; |
| 190 | + |
| 191 | + // Setup the cookie |
| 192 | + cookieStore.set(name, value); |
| 193 | + |
| 194 | + const result = getChunkedCookie(name, reqCookies); |
| 195 | + |
| 196 | + expect(result).toBe(value); |
| 197 | + expect(reqCookies.get).toHaveBeenCalledWith(name); |
| 198 | + }); |
| 199 | + |
| 200 | + it("should reconstruct value from chunks when cookie is chunked", () => { |
| 201 | + const name = "chunkedCookie"; |
| 202 | + const chunk0 = "chunk0 value"; |
| 203 | + const chunk1 = "chunk1 value"; |
| 204 | + const chunk2 = "chunk2 value"; |
| 205 | + |
| 206 | + // Add the chunks to the store (out of order) |
| 207 | + cookieStore.set(`${name}__1`, chunk1); |
| 208 | + cookieStore.set(`${name}__0`, chunk0); |
| 209 | + cookieStore.set(`${name}__2`, chunk2); |
| 210 | + |
| 211 | + // Also add some unrelated cookies |
| 212 | + cookieStore.set("otherCookie", "other value"); |
| 213 | + |
| 214 | + const result = getChunkedCookie(name, reqCookies); |
| 215 | + |
| 216 | + // Should combine chunks in proper order |
| 217 | + expect(result).toBe(`${chunk0}${chunk1}${chunk2}`); |
| 218 | + }); |
| 219 | + |
| 220 | + it("should return undefined when chunks are not in a complete sequence", () => { |
| 221 | + const name = "incompleteCookie"; |
| 222 | + |
| 223 | + // Add incomplete chunks (missing chunk1) |
| 224 | + cookieStore.set(`${name}__0`, "chunk0"); |
| 225 | + cookieStore.set(`${name}__2`, "chunk2"); |
| 226 | + |
| 227 | + const result = getChunkedCookie(name, reqCookies); |
| 228 | + |
| 229 | + expect(result).toBeUndefined(); |
| 230 | + expect(console.warn).toHaveBeenCalled(); |
| 231 | + }); |
| 232 | + }); |
| 233 | + |
| 234 | + describe("deleteChunkedCookie", () => { |
| 235 | + it("should delete the regular cookie", () => { |
| 236 | + const name = "regularCookie"; |
| 237 | + cookieStore.set(name, "regular value"); |
| 238 | + |
| 239 | + deleteChunkedCookie(name, reqCookies, resCookies); |
| 240 | + |
| 241 | + expect(resCookies.delete).toHaveBeenCalledWith(name); |
| 242 | + }); |
| 243 | + |
| 244 | + it("should delete all chunks of a cookie", () => { |
| 245 | + const name = "chunkedCookie"; |
| 246 | + |
| 247 | + // Add chunks |
| 248 | + cookieStore.set(`${name}__0`, "chunk0"); |
| 249 | + cookieStore.set(`${name}__1`, "chunk1"); |
| 250 | + cookieStore.set(`${name}__2`, "chunk2"); |
| 251 | + |
| 252 | + // Add unrelated cookie |
| 253 | + cookieStore.set("otherCookie", "other value"); |
| 254 | + |
| 255 | + deleteChunkedCookie(name, reqCookies, resCookies); |
| 256 | + |
| 257 | + // Should delete main cookie and 3 chunks |
| 258 | + expect(resCookies.delete).toHaveBeenCalledTimes(4); |
| 259 | + expect(resCookies.delete).toHaveBeenCalledWith(name); |
| 260 | + expect(resCookies.delete).toHaveBeenCalledWith(`${name}__0`); |
| 261 | + expect(resCookies.delete).toHaveBeenCalledWith(`${name}__1`); |
| 262 | + expect(resCookies.delete).toHaveBeenCalledWith(`${name}__2`); |
| 263 | + // Should not delete unrelated cookies |
| 264 | + expect(resCookies.delete).not.toHaveBeenCalledWith("otherCookie"); |
| 265 | + }); |
| 266 | + }); |
| 267 | + |
| 268 | + describe("Edge Cases", () => { |
| 269 | + it("should handle empty values correctly", () => { |
| 270 | + const name = "emptyCookie"; |
| 271 | + const value = ""; |
| 272 | + const options = { path: "/" } as CookieOptions; |
| 273 | + |
| 274 | + setChunkedCookie(name, value, options, reqCookies, resCookies); |
| 275 | + |
| 276 | + expect(resCookies.set).toHaveBeenCalledTimes(1); |
| 277 | + expect(resCookies.set).toHaveBeenCalledWith(name, value, options); |
| 278 | + }); |
| 279 | + |
| 280 | + it("should handle values at the exact chunk boundary", () => { |
| 281 | + const name = "boundaryValueCookie"; |
| 282 | + const value = "a".repeat(3500); // Exactly MAX_CHUNK_SIZE |
| 283 | + const options = { path: "/" } as CookieOptions; |
| 284 | + |
| 285 | + setChunkedCookie(name, value, options, reqCookies, resCookies); |
| 286 | + |
| 287 | + // Should still fit in one cookie |
| 288 | + expect(resCookies.set).toHaveBeenCalledTimes(1); |
| 289 | + expect(resCookies.set).toHaveBeenCalledWith(name, value, options); |
| 290 | + }); |
| 291 | + |
| 292 | + it("should handle special characters in cookie values", () => { |
| 293 | + const name = "specialCharCookie"; |
| 294 | + const value = |
| 295 | + '{"special":"characters","with":"quotation marks","and":"😀 emoji"}'; |
| 296 | + const options = { path: "/" } as CookieOptions; |
| 297 | + |
| 298 | + setChunkedCookie(name, value, options, reqCookies, resCookies); |
| 299 | + |
| 300 | + expect(resCookies.set).toHaveBeenCalledWith(name, value, options); |
| 301 | + |
| 302 | + // Setup for retrieval |
| 303 | + cookieStore.set(name, value); |
| 304 | + |
| 305 | + const result = getChunkedCookie(name, reqCookies); |
| 306 | + expect(result).toBe(value); |
| 307 | + }); |
| 308 | + |
| 309 | + it("should handle multi-byte characters correctly", () => { |
| 310 | + const name = "multiByteCookie"; |
| 311 | + // Create a test string with multi-byte characters (emojis) |
| 312 | + const value = "Hello 😀 world 🌍 with emojis 🎉"; |
| 313 | + const options = { path: "/" } as CookieOptions; |
| 314 | + |
| 315 | + // Store the cookie |
| 316 | + setChunkedCookie(name, value, options, reqCookies, resCookies); |
| 317 | + |
| 318 | + // For the retrieval test, manually set up the cookies |
| 319 | + // We're testing the retrieval functionality, not the chunking itself |
| 320 | + cookieStore.clear(); |
| 321 | + cookieStore.set(name, value); |
| 322 | + |
| 323 | + // Verify retrieval works correctly with multi-byte characters |
| 324 | + const result = getChunkedCookie(name, reqCookies); |
| 325 | + expect(result).toBe(value); |
| 326 | + |
| 327 | + // Verify emoji characters were preserved |
| 328 | + expect(result).toContain("😀"); |
| 329 | + expect(result).toContain("🌍"); |
| 330 | + expect(result).toContain("🎉"); |
| 331 | + }); |
| 332 | + |
| 333 | + it("should handle very large cookies properly", () => { |
| 334 | + const name = "veryLargeCookie"; |
| 335 | + const value = "a".repeat(10000); // Will create multiple chunks |
| 336 | + const options = { path: "/" } as CookieOptions; |
| 337 | + |
| 338 | + setChunkedCookie(name, value, options, reqCookies, resCookies); |
| 339 | + |
| 340 | + // Get chunks count (10000 / 3500 ≈ 2.86, so we need 3 chunks) |
| 341 | + const expectedChunks = Math.ceil(10000 / 3500); |
| 342 | + |
| 343 | + expect(resCookies.set).toHaveBeenCalledTimes(expectedChunks); |
| 344 | + |
| 345 | + // Clear and set up cookies for retrieval test |
| 346 | + cookieStore.clear(); |
| 347 | + |
| 348 | + // Setup for getChunkedCookie retrieval |
| 349 | + for (let i = 0; i < expectedChunks; i++) { |
| 350 | + const start = i * 3500; |
| 351 | + const end = Math.min((i + 1) * 3500, 10000); |
| 352 | + cookieStore.set(`${name}__${i}`, value.slice(start, end)); |
| 353 | + } |
| 354 | + |
| 355 | + const result = getChunkedCookie(name, reqCookies); |
| 356 | + expect(result).toBe(value); |
| 357 | + expect(result!.length).toBe(10000); |
| 358 | + }); |
| 359 | + }); |
| 360 | + }); |
| 361 | +}); |
0 commit comments