-
Notifications
You must be signed in to change notification settings - Fork 17
test(utils): add tests for misc, MemCache, and DiskCache #502
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
e1c04aa
test(utils): add tests for misc, MemCache, and DiskCache
marcoscaceres ac3b1cd
fix(tests): use try/catch for raw string throws and per-test env snap…
marcoscaceres 8620d21
Apply suggestion from @Copilot
marcoscaceres d71b925
Apply suggestions from code review
marcoscaceres 6c2b1b9
Apply suggestion from @Copilot
marcoscaceres 7dbbe88
fix(disk-cache): remove malformed TTL describe block and fix path-tra…
Copilot 4a0bcd6
fix(disk-cache-test): reset tempDir and use try/finally in afterEach
Copilot b3f97d1
Merge remote-tracking branch 'origin/main' into test/core-utils
Copilot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| import { DiskCache } from "../../build/utils/disk-cache.js"; | ||
| import { mkdtemp, rm } from "fs/promises"; | ||
| import { join } from "path"; | ||
| import { tmpdir } from "os"; | ||
|
|
||
| describe("utils/DiskCache", () => { | ||
| let tempDir; | ||
| let originalDataDir; | ||
|
|
||
| beforeEach(async () => { | ||
| tempDir = undefined; | ||
| originalDataDir = process.env.DATA_DIR; | ||
| tempDir = await mkdtemp(join(tmpdir(), "disk-cache-test-")); | ||
| process.env.DATA_DIR = tempDir; | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| try { | ||
| if (originalDataDir !== undefined) { | ||
| process.env.DATA_DIR = originalDataDir; | ||
| } else { | ||
| delete process.env.DATA_DIR; | ||
| } | ||
| } finally { | ||
| if (tempDir) { | ||
| await rm(tempDir, { recursive: true, force: true }); | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| it("round-trips set and get", async () => { | ||
| const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); | ||
| await cache.set("greeting", "hello"); | ||
| const result = await cache.get("greeting"); | ||
| expect(result).toBe("hello"); | ||
| }); | ||
|
|
||
| it("returns undefined for missing key", async () => { | ||
| const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); | ||
| const result = await cache.get("nonexistent"); | ||
| expect(result).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("stores complex objects", async () => { | ||
| const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); | ||
| const data = { name: "test", items: [1, 2, 3], nested: { a: true } }; | ||
| await cache.set("complex", data); | ||
| const result = await cache.get("complex"); | ||
| expect(result).toEqual(data); | ||
| }); | ||
|
|
||
| it("overwrites existing values", async () => { | ||
| const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); | ||
| await cache.set("key", "old"); | ||
| await cache.set("key", "new"); | ||
| expect(await cache.get("key")).toBe("new"); | ||
| }); | ||
|
|
||
| it("persists to disk and reads back in a fresh instance", async () => { | ||
| const opts = { ttl: 60_000, path: "test-cache" }; | ||
| const cache1 = new DiskCache(opts); | ||
| await cache1.set("persist", "data"); | ||
|
|
||
| // A new instance should read from disk | ||
| const cache2 = new DiskCache(opts); | ||
| expect(await cache2.get("persist")).toBe("data"); | ||
| }); | ||
|
|
||
| describe("time-dependent expiry", () => { | ||
| let now; | ||
|
|
||
| beforeEach(() => { | ||
| now = 1_000; | ||
| spyOn(Date, "now").and.callFake(() => now); | ||
| }); | ||
|
|
||
| describe("TTL expiry", () => { | ||
| it("returns undefined for expired entries", async () => { | ||
| // Use a very short TTL | ||
| const cache = new DiskCache({ ttl: 1, path: "test-cache" }); | ||
| await cache.set("key", "value"); | ||
|
|
||
| now += 10; | ||
| expect(await cache.get("key")).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("returns stale value when allowStale is true", async () => { | ||
| const cache = new DiskCache({ ttl: 1, path: "test-cache" }); | ||
| await cache.set("key", "value"); | ||
|
|
||
| now += 10; | ||
| expect(await cache.get("key", true)).toBe("value"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("invalidate()", () => { | ||
| it("removes expired entries from memory and disk", async () => { | ||
| const cache = new DiskCache({ ttl: 1, path: "test-cache" }); | ||
| await cache.set("a", 1); | ||
| await cache.set("b", 2); | ||
|
|
||
| now += 10; | ||
| await cache.invalidate(); | ||
|
|
||
| expect(await cache.get("a", true)).toBeUndefined(); | ||
| expect(await cache.get("b", true)).toBeUndefined(); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe("path traversal prevention", () => { | ||
| it("rejects keys with path traversal (..)", async () => { | ||
| const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); | ||
| await expectAsync(cache.set("foo/../evil", "data")).toBeRejectedWithError( | ||
| Error, | ||
| /Invalid (key|path)/i | ||
| ); | ||
| }); | ||
|
|
||
| it("rejects keys with double slashes", async () => { | ||
| const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); | ||
| await expectAsync(cache.set("foo//bar", "data")).toBeRejectedWithError( | ||
| Error, | ||
| /Invalid (key|path)/i | ||
| ); | ||
| }); | ||
|
|
||
| it("rejects keys with dot segments", async () => { | ||
| const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); | ||
| await expectAsync(cache.set("foo/./bar", "data")).toBeRejectedWithError( | ||
| Error, | ||
| /Invalid (key|path)/i | ||
| ); | ||
| }); | ||
|
|
||
| it("rejects prefix-bypass traversal (../test-cache-evil)", async () => { | ||
| // A key like ../test-cache-evil/pwn resolves to a path that *starts with* | ||
| // the base directory string but escapes it (e.g. /tmp/test-cache-evil). | ||
| // The check must use baseDir + sep to prevent this bypass. | ||
| const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); | ||
| await expectAsync( | ||
| cache.set("../test-cache-evil/pwn", "data") | ||
| ).toBeRejectedWithError(Error, /Invalid (key|path)/i); | ||
| }); | ||
| }); | ||
|
marcoscaceres marked this conversation as resolved.
|
||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,195 @@ | ||
| import { MemCache } from "../../build/utils/mem-cache.js"; | ||
|
|
||
| describe("utils/MemCache", () => { | ||
| describe("set() and get()", () => { | ||
| it("stores and retrieves a value", () => { | ||
| const cache = new MemCache(10_000); | ||
| cache.set("key1", "value1"); | ||
| expect(cache.get("key1")).toBe("value1"); | ||
| }); | ||
|
|
||
| it("returns undefined for missing key", () => { | ||
| const cache = new MemCache(10_000); | ||
| expect(cache.get("nonexistent")).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("overwrites existing value", () => { | ||
| const cache = new MemCache(10_000); | ||
| cache.set("key", "old"); | ||
| cache.set("key", "new"); | ||
| expect(cache.get("key")).toBe("new"); | ||
| }); | ||
|
|
||
| it("works with non-string values", () => { | ||
| const cache = new MemCache(10_000); | ||
| cache.set("obj", { a: 1 }); | ||
| expect(cache.get("obj")).toEqual({ a: 1 }); | ||
| cache.set("arr", [1, 2, 3]); | ||
| expect(cache.get("arr")).toEqual([1, 2, 3]); | ||
| }); | ||
| }); | ||
|
|
||
| describe("TTL expiry", () => { | ||
| it("returns undefined for expired entries", () => { | ||
| const cache = new MemCache(1000); // 1 second TTL | ||
| // Set entry with a time 2 seconds in the past | ||
| cache.set("old", "stale", Date.now() - 2000); | ||
| expect(cache.get("old")).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("returns value for non-expired entries", () => { | ||
| const cache = new MemCache(10_000); | ||
| cache.set("fresh", "value", Date.now()); | ||
| expect(cache.get("fresh")).toBe("value"); | ||
| }); | ||
|
|
||
| it("returns stale value when allowStale is true", () => { | ||
| const cache = new MemCache(1000); | ||
| cache.set("old", "stale", Date.now() - 2000); | ||
| expect(cache.get("old", true)).toBe("stale"); | ||
| }); | ||
|
|
||
| it("deletes expired entry from cache on non-stale get", () => { | ||
| const cache = new MemCache(1000); | ||
| cache.set("old", "stale", Date.now() - 2000); | ||
| // First get removes it | ||
| expect(cache.get("old")).toBeUndefined(); | ||
| // Even stale get now returns undefined because it was deleted | ||
| expect(cache.get("old", true)).toBeUndefined(); | ||
| }); | ||
| }); | ||
|
|
||
| describe("has()", () => { | ||
| it("returns true for existing non-expired key", () => { | ||
| const cache = new MemCache(10_000); | ||
| cache.set("key", "val"); | ||
| expect(cache.has("key")).toBeTrue(); | ||
| }); | ||
|
|
||
| it("returns false for missing key", () => { | ||
| const cache = new MemCache(10_000); | ||
| expect(cache.has("missing")).toBeFalse(); | ||
| }); | ||
|
|
||
| it("returns false for expired key without stale option", () => { | ||
| const cache = new MemCache(1000); | ||
| cache.set("old", "val", Date.now() - 2000); | ||
| expect(cache.has("old")).toBeFalse(); | ||
| }); | ||
|
|
||
| it("returns true for expired key with stale option", () => { | ||
| const cache = new MemCache(1000); | ||
| cache.set("old", "val", Date.now() - 2000); | ||
| expect(cache.has("old", true)).toBeTrue(); | ||
| }); | ||
| }); | ||
|
|
||
| describe("getOr()", () => { | ||
| it("returns cached value if present", () => { | ||
| const cache = new MemCache(10_000); | ||
| cache.set("key", "cached"); | ||
| const result = cache.getOr("key", () => "default"); | ||
| expect(result).toBe("cached"); | ||
| }); | ||
|
|
||
| it("calls defaultFunction and caches result for missing key", () => { | ||
| const cache = new MemCache(10_000); | ||
| let callCount = 0; | ||
| const factory = () => { | ||
| callCount++; | ||
| return "computed"; | ||
| }; | ||
| expect(cache.getOr("key", factory)).toBe("computed"); | ||
| expect(callCount).toBe(1); | ||
|
|
||
| // Second call uses cached value | ||
| expect(cache.getOr("key", factory)).toBe("computed"); | ||
| expect(callCount).toBe(1); | ||
| }); | ||
|
|
||
| it("recomputes when entry has expired", () => { | ||
| const cache = new MemCache(1000); | ||
| cache.set("key", "old", Date.now() - 2000); | ||
| const result = cache.getOr("key", () => "fresh"); | ||
| expect(result).toBe("fresh"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("delete()", () => { | ||
| it("removes an entry", () => { | ||
| const cache = new MemCache(10_000); | ||
| cache.set("key", "val"); | ||
| expect(cache.delete("key")).toBeTrue(); | ||
| expect(cache.get("key")).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("returns false for nonexistent key", () => { | ||
| const cache = new MemCache(10_000); | ||
| expect(cache.delete("nonexistent")).toBeFalse(); | ||
| }); | ||
| }); | ||
|
|
||
| describe("clear()", () => { | ||
| it("removes all entries", () => { | ||
| const cache = new MemCache(10_000); | ||
| cache.set("a", 1); | ||
| cache.set("b", 2); | ||
| cache.set("c", 3); | ||
| cache.clear(); | ||
| expect(cache.get("a")).toBeUndefined(); | ||
| expect(cache.get("b")).toBeUndefined(); | ||
| expect(cache.get("c")).toBeUndefined(); | ||
| }); | ||
| }); | ||
|
|
||
| describe("expires()", () => { | ||
| it("returns remaining TTL for a valid entry", () => { | ||
| const fixedNow = 1_700_000_000_000; | ||
| spyOn(Date, "now").and.returnValue(fixedNow); | ||
|
|
||
| const cache = new MemCache(10_000); | ||
| cache.set("key", "val", fixedNow); | ||
|
|
||
| expect(cache.expires("key")).toBe(10_000); | ||
| }); | ||
|
|
||
| it("returns 0 for missing key", () => { | ||
| const cache = new MemCache(10_000); | ||
| expect(cache.expires("nonexistent")).toBe(0); | ||
| }); | ||
|
|
||
| it("returns 0 for expired key", () => { | ||
| const cache = new MemCache(1000); | ||
| cache.set("old", "val", Date.now() - 2000); | ||
| expect(cache.expires("old")).toBe(0); | ||
| }); | ||
| }); | ||
|
|
||
| describe("invalidate()", () => { | ||
| it("removes expired entries and returns their keys", () => { | ||
| const cache = new MemCache(1000); | ||
| cache.set("old1", "v1", Date.now() - 2000); | ||
| cache.set("old2", "v2", Date.now() - 3000); | ||
| cache.set("fresh", "v3", Date.now()); | ||
|
|
||
| const invalidated = cache.invalidate(); | ||
| expect(invalidated).toContain("old1"); | ||
| expect(invalidated).toContain("old2"); | ||
| expect(invalidated).not.toContain("fresh"); | ||
| }); | ||
|
|
||
| it("returns empty array when nothing is expired", () => { | ||
| const cache = new MemCache(10_000); | ||
| cache.set("a", 1); | ||
| cache.set("b", 2); | ||
| expect(cache.invalidate()).toEqual([]); | ||
| }); | ||
|
|
||
| it("makes expired entries inaccessible", () => { | ||
| const cache = new MemCache(1000); | ||
| cache.set("old", "val", Date.now() - 2000); | ||
| cache.invalidate(); | ||
| expect(cache.get("old", true)).toBeUndefined(); | ||
| }); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.