diff --git a/jest.config.ts b/jest.config.ts index 958fd0b..eda9ad8 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -3,6 +3,8 @@ import type { Config } from "jest"; const config: Config = { testEnvironment: "node", clearMocks: true, + // forceExit closes open ioredis handles left by ioredis-mock after tests finish + forceExit: true, testMatch: ["/test/**/*.test.ts", "/src/**/*.spec.ts"], transform: { "^.+\\.ts$": ["ts-jest", { tsconfig: "tsconfig.json" }], @@ -27,10 +29,10 @@ const config: Config = { coverageDirectory: "coverage", coverageThreshold: { global: { - branches: 80, - functions: 80, - lines: 80, - statements: 80, + branches: 85, + functions: 85, + lines: 85, + statements: 85, }, }, moduleNameMapper: { diff --git a/package-lock.json b/package-lock.json index cc2bc3d..3017e1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "eslint-plugin-import": "^2.32.0", "globals": "^16.5.0", "husky": "^9.1.7", + "ioredis-mock": "^8.13.1", "jest": "^29.7.0", "lint-staged": "^16.2.7", "prettier": "^3.4.2", @@ -1363,6 +1364,13 @@ } } }, + "node_modules/@ioredis/as-callback": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@ioredis/as-callback/-/as-callback-3.0.0.tgz", + "integrity": "sha512-Kqv1rZ3WbgOrS+hgzJ5xG5WQuhvzzSTRYvNeyPMLOAM78MHSnuKI20JeJGbpuAt//LCuP0vsexZcorqW7kWhJg==", + "dev": true, + "license": "MIT" + }, "node_modules/@ioredis/commands": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", @@ -2625,6 +2633,17 @@ "@types/node": "*" } }, + "node_modules/@types/ioredis-mock": { + "version": "8.2.7", + "resolved": "https://registry.npmjs.org/@types/ioredis-mock/-/ioredis-mock-8.2.7.tgz", + "integrity": "sha512-YsGiaOIYBKeVvu/7GYziAD8qX3LJem5LK00d5PKykzsQJMLysAqXA61AkNuYWCekYl64tbMTqVOMF4SYoCPbQg==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "ioredis": ">=5" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -5164,6 +5183,35 @@ } } }, + "node_modules/fengari": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/fengari/-/fengari-0.1.5.tgz", + "integrity": "sha512-0DS4Nn4rV8qyFlQCpKK8brT61EUtswynrpfFTcgLErcilBIBskSMQ86fO2WVuybr14ywyKdRjv91FiRZwnEuvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "readline-sync": "^1.4.10", + "sprintf-js": "^1.1.3", + "tmp": "^0.2.5" + } + }, + "node_modules/fengari-interop": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/fengari-interop/-/fengari-interop-0.1.4.tgz", + "integrity": "sha512-4/CW/3PJUo3ebD4ACgE1g/3NGEYSq7OQAyETyypsAl/WeySDBbxExikkayNkZzbpgyC9GyJp8v1DU2VOXxNq7Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "fengari": "^0.1.0" + } + }, + "node_modules/fengari/node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", @@ -5952,6 +6000,27 @@ "url": "https://opencollective.com/ioredis" } }, + "node_modules/ioredis-mock": { + "version": "8.13.1", + "resolved": "https://registry.npmjs.org/ioredis-mock/-/ioredis-mock-8.13.1.tgz", + "integrity": "sha512-Wsi50AU+cMiI32nAgfwpUaJVBtb4iQdVsOHl9M6R3tePCO/8vGsToCVIG82XWAxN4Se55TZoOzVseu+QngFLyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ioredis/as-callback": "^3.0.0", + "@ioredis/commands": "^1.4.0", + "fengari": "^0.1.4", + "fengari-interop": "^0.1.3", + "semver": "^7.7.2" + }, + "engines": { + "node": ">=12.22" + }, + "peerDependencies": { + "@types/ioredis-mock": "^8", + "ioredis": "^5" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -8651,6 +8720,16 @@ "node": ">= 6" } }, + "node_modules/readline-sync": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/readline-sync/-/readline-sync-1.4.10.tgz", + "integrity": "sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", @@ -9634,6 +9713,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", diff --git a/package.json b/package.json index fd7b393..aba7a47 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "eslint-plugin-import": "^2.32.0", "globals": "^16.5.0", "husky": "^9.1.7", + "ioredis-mock": "^8.13.1", "jest": "^29.7.0", "lint-staged": "^16.2.7", "prettier": "^3.4.2", diff --git a/src/adapters/in-memory-cache-store.adapter.spec.ts b/src/adapters/in-memory-cache-store.adapter.spec.ts new file mode 100644 index 0000000..53afbcf --- /dev/null +++ b/src/adapters/in-memory-cache-store.adapter.spec.ts @@ -0,0 +1,182 @@ +/** + * @file in-memory-cache-store.adapter.spec.ts + * + * Unit tests for InMemoryCacheStore — the Map-backed ICacheStore adapter. + * + * Tests cover: + * - Full ICacheStore contract: get, set, delete, clear + * - TTL expiry: entries expire after TTL elapses and are present before + * - Parse-error resilience: malformed JSON stored directly returns null + * - No-expiry behaviour: entries without TTL persist until explicitly cleared + */ + +import { InMemoryCacheStore } from "./in-memory-cache-store.adapter"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Advance Date.now() by `ms` milliseconds inside a test block */ +function advanceTimeBy(ms: number): void { + jest.spyOn(Date, "now").mockReturnValue(Date.now() + ms); +} + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe("InMemoryCacheStore", () => { + let store: InMemoryCacheStore; + + // Create a fresh, empty store before every test so state never leaks + beforeEach(() => { + store = new InMemoryCacheStore(); + jest.restoreAllMocks(); // reset Date.now() spy between tests + }); + + // ── get ────────────────────────────────────────────────────────────────── + + describe("get()", () => { + it("returns null for a key that was never set", async () => { + // Querying an empty store must return a cache miss + const result = await store.get("missing"); + expect(result).toBeNull(); + }); + + it("returns the stored value on a cache hit", async () => { + // Store a plain object, then retrieve it + await store.set("key", { name: "Alice" }); + const result = await store.get<{ name: string }>("key"); + expect(result).toEqual({ name: "Alice" }); + }); + + it("returns null for a key that has been deleted", async () => { + // Set then immediately delete — get() must return null + await store.set("key", "value"); + await store.delete("key"); + expect(await store.get("key")).toBeNull(); + }); + + it("returns null when stored JSON is malformed (parse error → null)", async () => { + // Bypass public API and inject invalid JSON directly into the backing Map + // to test the try/catch parse-error path + const raw = (store as unknown as { store: Map }) + .store; + raw.set("bad", { value: "not-valid-json{{", expiresAt: null }); + + const result = await store.get("bad"); + expect(result).toBeNull(); + }); + }); + + // ── set ────────────────────────────────────────────────────────────────── + + describe("set()", () => { + it("overwrites an existing value for the same key", async () => { + // Second set() must replace the first + await store.set("key", "first"); + await store.set("key", "second"); + expect(await store.get("key")).toBe("second"); + }); + + it("stores primitive, array, and object values correctly", async () => { + // Validates JSON round-trip for different value shapes + await store.set("num", 42); + await store.set("arr", [1, 2, 3]); + await store.set("obj", { a: 1 }); + + expect(await store.get("num")).toBe(42); + expect(await store.get("arr")).toEqual([1, 2, 3]); + expect(await store.get("obj")).toEqual({ a: 1 }); + }); + + it("entry without TTL persists indefinitely", async () => { + // No TTL means the entry should survive any time advance + await store.set("persistent", "value"); + advanceTimeBy(999_999_000); // advance ~11.5 days + expect(await store.get("persistent")).toBe("value"); + }); + + it("entry with TTL=0 is treated as no expiry", async () => { + // TTL of 0 is the same as omitting the TTL + await store.set("key", "value", 0); + advanceTimeBy(5_000); // advance 5 seconds + expect(await store.get("key")).toBe("value"); + }); + }); + + // ── TTL ────────────────────────────────────────────────────────────────── + + describe("TTL expiry", () => { + it("entry is present before TTL elapses", async () => { + // Entry set with 10-second TTL must be readable immediately + await store.set("ttl-key", "alive", 10); + expect(await store.get("ttl-key")).toBe("alive"); + }); + + it("entry expires and returns null after TTL elapses", async () => { + // Set with 5-second TTL, then advance time past the deadline + await store.set("ttl-key", "bye", 5); + advanceTimeBy(6_000); // 6 s > 5 s TTL + expect(await store.get("ttl-key")).toBeNull(); + }); + + it("expired entry is removed from the store on access (lazy eviction)", async () => { + // After expiry + access, the backing Map must no longer hold the entry + await store.set("ttl-key", "stale", 1); + advanceTimeBy(2_000); + await store.get("ttl-key"); // triggers lazy delete + + const raw = (store as unknown as { store: Map }).store; + expect(raw.has("ttl-key")).toBe(false); + }); + }); + + // ── delete ─────────────────────────────────────────────────────────────── + + describe("delete()", () => { + it("removes an existing entry", async () => { + await store.set("key", "value"); + await store.delete("key"); + expect(await store.get("key")).toBeNull(); + }); + + it("is a no-op when the key does not exist (does not throw)", async () => { + // Deleting a missing key must succeed silently + await expect(store.delete("ghost")).resolves.toBeUndefined(); + }); + }); + + // ── clear ───────────────────────────────────────────────────────────────── + + describe("clear()", () => { + it("removes all entries from the store", async () => { + // Populate with several entries then clear + await store.set("a", 1); + await store.set("b", 2); + await store.set("c", 3); + + await store.clear(); + + // All three keys must be gone + expect(await store.get("a")).toBeNull(); + expect(await store.get("b")).toBeNull(); + expect(await store.get("c")).toBeNull(); + }); + + it("is safe to call on an empty store", async () => { + // Clearing an empty store must not throw + await expect(store.clear()).resolves.toBeUndefined(); + }); + }); + + // ── has (via CacheService — tested through adapter directly) ───────────── + + describe("get() after clear()", () => { + it("returns null for a key that existed before clear()", async () => { + await store.set("key", "value"); + await store.clear(); + expect(await store.get("key")).toBeNull(); + }); + }); +}); diff --git a/src/adapters/redis-cache-store.adapter.spec.ts b/src/adapters/redis-cache-store.adapter.spec.ts new file mode 100644 index 0000000..35a7732 --- /dev/null +++ b/src/adapters/redis-cache-store.adapter.spec.ts @@ -0,0 +1,191 @@ +/** + * @file redis-cache-store.adapter.spec.ts + * + * Unit tests for RedisCacheStore — the ioredis-backed ICacheStore adapter. + * + * Uses ioredis-mock so no real Redis server is required. The mock exposes the + * same API surface as real ioredis, meaning all RedisCacheStore code paths + * (get, set+EX, del, keys+del, flushdb) are exercised against in-memory state. + * + * Tests cover: + * - Full ICacheStore contract: get, set (with and without TTL), delete, clear + * - Key prefix namespacing: keys are stored and retrieved with the prefix + * - clear() with prefix: only prefixed keys are removed + * - clear() without prefix: full flushdb is called + * - Parse-error resilience: malformed JSON stored in Redis returns null + * - Constructor accepts both a URL string and an existing Redis instance + */ + +// ioredis-mock is a drop-in in-memory replacement for ioredis used during tests +import RedisMock from "ioredis-mock"; + +import { RedisCacheStore } from "./redis-cache-store.adapter"; + +// Derive the instance type from the constructor to avoid the namespace-as-type error +type RedisMockInstance = InstanceType; + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe("RedisCacheStore", () => { + // ── Without key prefix ─────────────────────────────────────────────────── + + describe("without keyPrefix", () => { + let redis: RedisMockInstance; + let store: RedisCacheStore; + + beforeEach(() => { + // Create a fresh mock client before every test to avoid state leaking + redis = new RedisMock(); + // Pass the mock client directly (exercises the "existing client" code path) + store = new RedisCacheStore({ client: redis as never }); + }); + + // ── get ───────────────────────────────────────────────────────────── + + describe("get()", () => { + it("returns null for a key that does not exist", async () => { + expect(await store.get("missing")).toBeNull(); + }); + + it("returns the deserialized value on a cache hit", async () => { + // Pre-populate the mock Redis directly with a serialized value + await redis.set("key", JSON.stringify({ id: 1 })); + const result = await store.get<{ id: number }>("key"); + expect(result).toEqual({ id: 1 }); + }); + + it("returns null when the stored value is malformed JSON", async () => { + // Store invalid JSON directly in the mock — RedisCacheStore must return null + await redis.set("bad", "not-json{{"); + expect(await store.get("bad")).toBeNull(); + }); + }); + + // ── set ───────────────────────────────────────────────────────────── + + describe("set()", () => { + it("stores a value without TTL and it persists", async () => { + await store.set("key", { name: "Bob" }); + // Verify the raw string is present in the mock + const raw = await redis.get("key"); + expect(JSON.parse(raw!)).toEqual({ name: "Bob" }); + }); + + it("stores a value with TTL using the EX flag", async () => { + // Spy on the mock redis.set to confirm EX is passed + const setSpy = jest.spyOn(redis, "set"); + await store.set("key", "value", 30); + // EX and the TTL value must appear as arguments + expect(setSpy).toHaveBeenCalledWith("key", '"value"', "EX", 30); + }); + + it("stores a value without TTL when ttlSeconds is 0", async () => { + const setSpy = jest.spyOn(redis, "set"); + await store.set("key", "value", 0); + // Should call the two-argument overload (no EX) + expect(setSpy).toHaveBeenCalledWith("key", '"value"'); + }); + }); + + // ── delete ─────────────────────────────────────────────────────────── + + describe("delete()", () => { + it("removes an existing key", async () => { + await redis.set("key", JSON.stringify("hello")); + await store.delete("key"); + expect(await redis.get("key")).toBeNull(); + }); + + it("is a no-op when the key does not exist", async () => { + // Must not throw even when the key is absent + await expect(store.delete("ghost")).resolves.toBeUndefined(); + }); + }); + + // ── clear (no prefix → flushdb) ────────────────────────────────────── + + describe("clear() without prefix", () => { + it("calls flushdb and empties the entire Redis database", async () => { + const flushSpy = jest.spyOn(redis, "flushdb"); + await redis.set("a", "1"); + await redis.set("b", "2"); + + await store.clear(); + + // flushdb must have been called once + expect(flushSpy).toHaveBeenCalledTimes(1); + // Both keys must be gone from the mock + expect(await redis.get("a")).toBeNull(); + expect(await redis.get("b")).toBeNull(); + }); + }); + }); + + // ── With key prefix ────────────────────────────────────────────────────── + + describe("with keyPrefix", () => { + let redis: RedisMockInstance; + let store: RedisCacheStore; + const PREFIX = "app"; + + beforeEach(() => { + redis = new RedisMock(); + store = new RedisCacheStore({ client: redis as never, keyPrefix: PREFIX }); + }); + + it("prefixes every key on set and get", async () => { + // After set("user"), the raw Redis key must be "app:user" + await store.set("user", { id: 42 }); + const raw = await redis.get(`${PREFIX}:user`); + expect(JSON.parse(raw!)).toEqual({ id: 42 }); + }); + + it("get() resolves using the prefixed key", async () => { + // Pre-populate under the prefixed key, then get() without the prefix + await redis.set(`${PREFIX}:item`, JSON.stringify("cached")); + expect(await store.get("item")).toBe("cached"); + }); + + it("delete() removes the prefixed key", async () => { + await redis.set(`${PREFIX}:key`, JSON.stringify("v")); + await store.delete("key"); + expect(await redis.get(`${PREFIX}:key`)).toBeNull(); + }); + + // ── clear (with prefix → keys + del) ──────────────────────────────── + + describe("clear() with prefix", () => { + it("removes only keys matching the prefix pattern", async () => { + // Populate both prefixed and non-prefixed keys + await redis.set(`${PREFIX}:x`, "1"); + await redis.set(`${PREFIX}:y`, "2"); + await redis.set("other:z", "3"); // must NOT be deleted + + await store.clear(); + + // Prefixed keys are gone + expect(await redis.get(`${PREFIX}:x`)).toBeNull(); + expect(await redis.get(`${PREFIX}:y`)).toBeNull(); + // Non-prefixed key is untouched + expect(await redis.get("other:z")).toBe("3"); + }); + + it("does not call DEL when no prefixed keys exist", async () => { + const delSpy = jest.spyOn(redis, "del"); + await store.clear(); // nothing under "app:*" + expect(delSpy).not.toHaveBeenCalled(); + }); + }); + }); + + // ── URL string constructor ──────────────────────────────────────────────── + + describe("constructor with URL string", () => { + it("accepts a connection URL string (exercises the string branch)", () => { + // Passing a URL string must not throw — the constructor creates an internal client + expect(() => new RedisCacheStore({ client: "redis://localhost:6379" })).not.toThrow(); + }); + }); +}); diff --git a/src/cache-kit.module.spec.ts b/src/cache-kit.module.spec.ts new file mode 100644 index 0000000..069d776 --- /dev/null +++ b/src/cache-kit.module.spec.ts @@ -0,0 +1,115 @@ +/** + * @file cache-kit.module.spec.ts + * + * Unit tests for CacheModule — the NestJS dynamic module. + * + * Tests cover: + * - register() wires the InMemory adapter when store is "memory" + * - register() wires the Redis adapter when store is "redis" + * - register() throws when store is "redis" but no redis options are given + * - registerAsync() resolves options via useFactory and wires CacheService + * - onModuleInit() populates CacheServiceRef with the CacheService instance + */ + +import { Test } from "@nestjs/testing"; +import { CacheServiceRef } from "@utils/cache-service-ref"; + +import { CacheModule } from "./cache-kit.module"; +import { CacheService } from "./services/cache.service"; + +describe("CacheModule", () => { + // ── register() — memory store ───────────────────────────────────────────── + + describe("register() with store: memory", () => { + it("provides CacheService and it is injectable", async () => { + // Compile a minimal NestJS module using the synchronous registration path + const module = await Test.createTestingModule({ + imports: [CacheModule.register({ store: "memory" })], + }).compile(); + + // CacheService must be resolvable from the module's DI container + const service = module.get(CacheService); + expect(service).toBeInstanceOf(CacheService); + }); + + it("CacheService.get() returns null for an unknown key (full integration)", async () => { + const module = await Test.createTestingModule({ + imports: [CacheModule.register({ store: "memory", ttl: 60 })], + }).compile(); + + const service = module.get(CacheService); + expect(await service.get("unknown")).toBeNull(); + }); + + it("exposes get/set/has/delete/clear/wrap through CacheService", async () => { + const module = await Test.createTestingModule({ + imports: [CacheModule.register({ store: "memory" })], + }).compile(); + + const service = module.get(CacheService); + // Basic round-trip: set then get + await service.set("key", "value"); + expect(await service.get("key")).toBe("value"); + expect(await service.has("key")).toBe(true); + await service.delete("key"); + expect(await service.get("key")).toBeNull(); + }); + }); + + // ── register() — redis store validation ────────────────────────────────── + + describe("register() with store: redis", () => { + it("throws when no redis options are provided", () => { + // The factory function is called at registration time for synchronous options + expect(() => CacheModule.register({ store: "redis" })).toThrow(/redis.*options/i); + }); + }); + + // ── registerAsync() ─────────────────────────────────────────────────────── + + describe("registerAsync() with useFactory", () => { + it("resolves options from the factory and provides CacheService", async () => { + const module = await Test.createTestingModule({ + imports: [ + CacheModule.registerAsync({ + // useFactory resolves synchronously here for simplicity + useFactory: () => ({ store: "memory" as const }), + }), + ], + }).compile(); + + const service = module.get(CacheService); + expect(service).toBeInstanceOf(CacheService); + }); + + it("resolves options from an async factory (Promise)", async () => { + const module = await Test.createTestingModule({ + imports: [ + CacheModule.registerAsync({ + useFactory: async () => ({ store: "memory" as const, ttl: 30 }), + }), + ], + }).compile(); + + const service = module.get(CacheService); + expect(service).toBeInstanceOf(CacheService); + }); + }); + + // ── onModuleInit() populates CacheServiceRef ────────────────────────────── + + describe("onModuleInit()", () => { + it("populates CacheServiceRef so @Cacheable / @CacheEvict can resolve the service", async () => { + const module = await Test.createTestingModule({ + imports: [CacheModule.register({ store: "memory" })], + }).compile(); + + // init() triggers onModuleInit on all providers + await module.init(); + + // CacheServiceRef.get() must succeed (not throw) after init + expect(() => CacheServiceRef.get()).not.toThrow(); + expect(CacheServiceRef.get()).toBeInstanceOf(CacheService); + }); + }); +}); diff --git a/src/decorators/cache-evict.decorator.spec.ts b/src/decorators/cache-evict.decorator.spec.ts new file mode 100644 index 0000000..7edc7b0 --- /dev/null +++ b/src/decorators/cache-evict.decorator.spec.ts @@ -0,0 +1,161 @@ +/** + * @file cache-evict.decorator.spec.ts + * + * Unit tests for the @CacheEvict method decorator. + * + * Uses a real InMemoryCacheStore and CacheService (via CacheServiceRef) so the + * complete eviction path is exercised end-to-end without mocking internals. + * + * Tests cover: + * - Cache entry is deleted after the method executes successfully + * - The method return value is preserved and returned to the caller + * - Cache entry is NOT deleted when the method throws (eviction is skipped) + * - Key template interpolation: "user:{0}" with arg "42" → entry "user:42" is evicted + * - Works on async methods + * - Works on sync methods + */ + +import { InMemoryCacheStore } from "@adapters/in-memory-cache-store.adapter"; +import { CacheServiceRef } from "@utils/cache-service-ref"; + +import type { CacheModuleOptions } from "../cache-kit.module"; +import { CacheService } from "../services/cache.service"; + +import { CacheEvict } from "./cache-evict.decorator"; + +// --------------------------------------------------------------------------- +// Setup helpers +// --------------------------------------------------------------------------- + +/** Wire a real CacheService backed by a fresh InMemoryCacheStore into CacheServiceRef */ +function setupCacheServiceRef(): { service: CacheService; store: InMemoryCacheStore } { + const store = new InMemoryCacheStore(); + const options: CacheModuleOptions = { store: "memory" }; + const service = new (CacheService as new ( + store: InMemoryCacheStore, + options: CacheModuleOptions, + ) => CacheService)(store, options); + CacheServiceRef.set(service); + return { service, store }; +} + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe("@CacheEvict decorator", () => { + let service: CacheService; + + beforeEach(() => { + ({ service } = setupCacheServiceRef()); + }); + + // ── Basic eviction ──────────────────────────────────────────────────────── + + it("deletes the specified cache entry after the method executes", async () => { + // Pre-populate the cache so we can verify it disappears + await service.set("product:1", { price: 99 }); + + class ProductService { + @CacheEvict("product:1") + async updateProduct() { + return { updated: true }; + } + } + + await new ProductService().updateProduct(); + + // The entry must be gone after the decorated method ran + expect(await service.get("product:1")).toBeNull(); + }); + + it("returns the original method's return value unchanged", async () => { + class OrderService { + @CacheEvict("order:1") + async createOrder() { + return { orderId: "abc" }; + } + } + + const result = await new OrderService().createOrder(); + // The decorator must not modify the return value + expect(result).toEqual({ orderId: "abc" }); + }); + + // ── Error path ──────────────────────────────────────────────────────────── + + it("does NOT evict the cache entry when the method throws", async () => { + // Pre-populate an entry that must survive the throw + await service.set("safe-key", "keep-me"); + + class FailingService { + @CacheEvict("safe-key") + async doWork(): Promise { + throw new Error("operation failed"); + } + } + + await expect(new FailingService().doWork()).rejects.toThrow("operation failed"); + + // The cache entry must be intact because eviction is skipped on failure + expect(await service.get("safe-key")).toBe("keep-me"); + }); + + // ── Key template interpolation ──────────────────────────────────────────── + + it('resolves "user:{0}" with arg "42" and evicts exactly "user:42"', async () => { + // Populate two keys to confirm only the matching one is removed + await service.set("user:42", { name: "Alice" }); + await service.set("user:99", { name: "Bob" }); + + class UserService { + @CacheEvict("user:{0}") + async deleteUser(id: string) { + return { deleted: id }; + } + } + + await new UserService().deleteUser("42"); + + // "user:42" evicted, "user:99" untouched + expect(await service.get("user:42")).toBeNull(); + expect(await service.get("user:99")).toEqual({ name: "Bob" }); + }); + + // ── Sync method support ─────────────────────────────────────────────────── + + it("works on synchronous methods", async () => { + await service.set("sync-key", "cached"); + + class SyncService { + @CacheEvict("sync-key") + doSync() { + return "done"; + } + } + + const result = await new SyncService().doSync(); + + expect(result).toBe("done"); + expect(await service.get("sync-key")).toBeNull(); + }); + + // ── After eviction, re-population works ────────────────────────────────── + + it("allows re-population of the evicted key on the next set()", async () => { + await service.set("item:1", "old-value"); + + class ItemService { + @CacheEvict("item:1") + async update() { + return "updated"; + } + } + + await new ItemService().update(); + // Evicted — now re-populate + await service.set("item:1", "new-value"); + + expect(await service.get("item:1")).toBe("new-value"); + }); +}); diff --git a/src/decorators/cacheable.decorator.spec.ts b/src/decorators/cacheable.decorator.spec.ts new file mode 100644 index 0000000..05fecaf --- /dev/null +++ b/src/decorators/cacheable.decorator.spec.ts @@ -0,0 +1,176 @@ +/** + * @file cacheable.decorator.spec.ts + * + * Unit tests for the @Cacheable method decorator. + * + * The decorator resolves CacheService via CacheServiceRef at runtime. + * Tests use a real InMemoryCacheStore wired into a real CacheService so + * the full stack is exercised without spinning up a NestJS app. + * + * Tests cover: + * - Cache hit: original method NOT called on second invocation + * - Cache miss: original method called and result persisted on first call + * - Key template interpolation: "user:{0}" with arg "42" → "user:42" + * - Optional TTL forwarded to the store + * - Works on async methods + * - Works on sync methods + */ + +import { InMemoryCacheStore } from "@adapters/in-memory-cache-store.adapter"; +import { CacheServiceRef } from "@utils/cache-service-ref"; + +import type { CacheModuleOptions } from "../cache-kit.module"; +import { CacheService } from "../services/cache.service"; + +import { Cacheable } from "./cacheable.decorator"; + +// --------------------------------------------------------------------------- +// Setup helpers +// --------------------------------------------------------------------------- + +/** Wire a real CacheService into CacheServiceRef so decorators work */ +function setupCacheServiceRef(ttl?: number): CacheService { + const store = new InMemoryCacheStore(); + const options: CacheModuleOptions = + ttl !== undefined ? { store: "memory", ttl } : { store: "memory" }; + const service = new (CacheService as new ( + store: InMemoryCacheStore, + options: CacheModuleOptions, + ) => CacheService)(store, options); + CacheServiceRef.set(service); + return service; +} + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe("@Cacheable decorator", () => { + let cacheService: CacheService; + + beforeEach(() => { + // Fresh CacheService (and backing store) before every test + cacheService = setupCacheServiceRef(); + }); + + // ── Cache miss then hit ─────────────────────────────────────────────────── + + it("calls the underlying method on the first call (cache miss)", async () => { + const impl = jest.fn().mockResolvedValue({ id: 1 }); + + class UserService { + @Cacheable("user:1") + async findUser() { + return impl(); + } + } + + const svc = new UserService(); + const result = await svc.findUser(); + + // impl must have been called exactly once + expect(impl).toHaveBeenCalledTimes(1); + expect(result).toEqual({ id: 1 }); + }); + + it("returns the cached value and does NOT call the method on subsequent calls", async () => { + const impl = jest.fn().mockResolvedValue({ id: 1 }); + + class UserService { + @Cacheable("user:static") + async findUser() { + return impl(); + } + } + + const svc = new UserService(); + await svc.findUser(); // miss — populates cache + const result = await svc.findUser(); // hit — must NOT call impl again + + expect(impl).toHaveBeenCalledTimes(1); + expect(result).toEqual({ id: 1 }); + }); + + // ── Key template interpolation ──────────────────────────────────────────── + + it('resolves "user:{0}" with argument "42" to cache key "user:42"', async () => { + const impl = jest.fn().mockResolvedValue({ name: "Alice" }); + + class UserService { + @Cacheable("user:{0}") + async findById(id: string) { + return impl(id); + } + } + + const svc = new UserService(); + await svc.findById("42"); + // Second call with the same argument hits the cached entry + const result = await svc.findById("42"); + + expect(impl).toHaveBeenCalledTimes(1); + expect(result).toEqual({ name: "Alice" }); + + // Different argument → different cache key → another miss + await svc.findById("99"); + expect(impl).toHaveBeenCalledTimes(2); + }); + + it("stores different results for different argument values", async () => { + const impl = jest.fn().mockImplementation(async (id: string) => ({ id })); + + class UserService { + @Cacheable("user:{0}") + async findById(id: string) { + return impl(id); + } + } + + const svc = new UserService(); + const r1 = await svc.findById("1"); + const r2 = await svc.findById("2"); + + expect(r1).toEqual({ id: "1" }); + expect(r2).toEqual({ id: "2" }); + expect(impl).toHaveBeenCalledTimes(2); + }); + + // ── TTL forwarding ──────────────────────────────────────────────────────── + + it("forwards the TTL to CacheService.set()", async () => { + const setSpy = jest.spyOn(cacheService, "set"); + const impl = jest.fn().mockResolvedValue("data"); + + class DataService { + @Cacheable("data-key", 60) + async fetch() { + return impl(); + } + } + + await new DataService().fetch(); + // The explicit 60-second TTL must have been passed to set() + expect(setSpy).toHaveBeenCalledWith("data-key", "data", 60); + }); + + // ── Sync method support ─────────────────────────────────────────────────── + + it("works on synchronous methods", async () => { + const impl = jest.fn().mockReturnValue(42); + + class CalcService { + @Cacheable("sync-key") + compute() { + return impl(); + } + } + + const svc = new CalcService(); + const r1 = await svc.compute(); + const r2 = await svc.compute(); + + expect(impl).toHaveBeenCalledTimes(1); + expect(r1).toBe(42); + expect(r2).toBe(42); + }); +}); diff --git a/src/services/cache.service.spec.ts b/src/services/cache.service.spec.ts new file mode 100644 index 0000000..34f65b9 --- /dev/null +++ b/src/services/cache.service.spec.ts @@ -0,0 +1,204 @@ +/** + * @file cache.service.spec.ts + * + * Unit tests for CacheService — the primary public API for caching. + * + * Uses an InMemoryCacheStore as the backing adapter instead of mocking the + * ICacheStore interface so that full integration through the real store is + * exercised without requiring a Redis server. + * + * Tests cover: + * - get / set / delete / clear delegating correctly to the store + * - TTL resolution: per-call TTL overrides module default; no TTL falls back to default + * - has(): returns true when a live entry exists, false otherwise + * - wrap(): calls fn once on miss, returns cached value on subsequent calls + */ + +import { InMemoryCacheStore } from "@adapters/in-memory-cache-store.adapter"; +import type { ICacheStore } from "@ports/cache-store.port"; + +import type { CacheModuleOptions } from "../cache-kit.module"; +import { CACHE_MODULE_OPTIONS, CACHE_STORE } from "../constants"; + +import { CacheService } from "./cache.service"; + +// --------------------------------------------------------------------------- +// Factory helpers +// --------------------------------------------------------------------------- + +/** Build a CacheService wired with the provided store and options */ +function buildService(store: ICacheStore, options: Partial = {}): CacheService { + // Manually construct with the two required inject tokens + // (avoids spinning up a full NestJS testing module for pure unit tests) + return new (CacheService as new ( + store: ICacheStore, + options: CacheModuleOptions, + ) => CacheService)( + store, + // Merge supplied options with a safe default (memory store, no default TTL) + { store: "memory", ...options }, + ); +} + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe("CacheService", () => { + let store: InMemoryCacheStore; + let service: CacheService; + + beforeEach(() => { + // Use a fresh InMemoryCacheStore so tests are isolated from one another + store = new InMemoryCacheStore(); + service = buildService(store); + }); + + // ── get ────────────────────────────────────────────────────────────────── + + describe("get()", () => { + it("returns null when the key does not exist", async () => { + expect(await service.get("absent")).toBeNull(); + }); + + it("returns the stored value when the key exists", async () => { + await store.set("key", { x: 1 }); + expect(await service.get("key")).toEqual({ x: 1 }); + }); + }); + + // ── set ────────────────────────────────────────────────────────────────── + + describe("set()", () => { + it("stores the value and it is retrievable via get()", async () => { + await service.set("key", "hello"); + expect(await service.get("key")).toBe("hello"); + }); + + it("uses per-call TTL when provided", async () => { + // Spy on the store to verify the exact TTL argument forwarded + const spy = jest.spyOn(store, "set"); + await service.set("key", "v", 60); + expect(spy).toHaveBeenCalledWith("key", "v", 60); + }); + + it("falls back to module default TTL when no per-call TTL is given", async () => { + // Build a service with a default TTL of 30 s + const svcWithDefault = buildService(store, { ttl: 30 }); + const spy = jest.spyOn(store, "set"); + await svcWithDefault.set("key", "v"); + expect(spy).toHaveBeenCalledWith("key", "v", 30); + }); + + it("passes undefined TTL when neither per-call nor module default is set", async () => { + const spy = jest.spyOn(store, "set"); + await service.set("key", "v"); // service has no default TTL + expect(spy).toHaveBeenCalledWith("key", "v", undefined); + }); + }); + + // ── delete ─────────────────────────────────────────────────────────────── + + describe("delete()", () => { + it("removes an existing entry", async () => { + await service.set("key", "value"); + await service.delete("key"); + expect(await service.get("key")).toBeNull(); + }); + + it("does not throw when deleting a non-existent key", async () => { + await expect(service.delete("ghost")).resolves.toBeUndefined(); + }); + }); + + // ── clear ───────────────────────────────────────────────────────────────── + + describe("clear()", () => { + it("removes all entries from the store", async () => { + await service.set("a", 1); + await service.set("b", 2); + await service.clear(); + expect(await service.get("a")).toBeNull(); + expect(await service.get("b")).toBeNull(); + }); + }); + + // ── has ────────────────────────────────────────────────────────────────── + + describe("has()", () => { + it("returns true when a live entry exists", async () => { + await service.set("key", "value"); + expect(await service.has("key")).toBe(true); + }); + + it("returns false when the key does not exist", async () => { + expect(await service.has("missing")).toBe(false); + }); + + it("returns false after the entry has been deleted", async () => { + await service.set("key", "value"); + await service.delete("key"); + expect(await service.has("key")).toBe(false); + }); + }); + + // ── wrap ───────────────────────────────────────────────────────────────── + + describe("wrap()", () => { + it("calls fn and caches the result on the first call (cache miss)", async () => { + const fn = jest.fn().mockResolvedValue({ id: 1 }); + + const result = await service.wrap("key", fn); + + // fn must have been invoked exactly once + expect(fn).toHaveBeenCalledTimes(1); + expect(result).toEqual({ id: 1 }); + }); + + it("returns the cached value without calling fn on subsequent calls", async () => { + const fn = jest.fn().mockResolvedValue({ id: 1 }); + + // First call populates the cache + await service.wrap("key", fn); + // Second call must be a cache hit — fn should NOT be called again + const result = await service.wrap("key", fn); + + expect(fn).toHaveBeenCalledTimes(1); + expect(result).toEqual({ id: 1 }); + }); + + it("uses the per-call TTL when provided", async () => { + const spy = jest.spyOn(store, "set"); + await service.wrap("key", async () => "val", 45); + expect(spy).toHaveBeenCalledWith("key", "val", 45); + }); + + it("falls back to the module default TTL when no TTL is passed to wrap()", async () => { + const svcWithDefault = buildService(store, { ttl: 120 }); + const spy = jest.spyOn(store, "set"); + await svcWithDefault.wrap("key", async () => "val"); + expect(spy).toHaveBeenCalledWith("key", "val", 120); + }); + + it("propagates errors thrown by fn without caching anything", async () => { + const fn = jest.fn().mockRejectedValue(new Error("db failure")); + + await expect(service.wrap("key", fn)).rejects.toThrow("db failure"); + // The key must not have been cached + expect(await service.get("key")).toBeNull(); + }); + }); + + // ── DI token constants (smoke) ──────────────────────────────────────────── + + describe("DI token constants", () => { + it("CACHE_STORE token equals the expected string", () => { + // Guards against accidental token renames + expect(CACHE_STORE).toBe("CACHE_STORE"); + }); + + it("CACHE_MODULE_OPTIONS token equals the expected string", () => { + expect(CACHE_MODULE_OPTIONS).toBe("CACHE_MODULE_OPTIONS"); + }); + }); +}); diff --git a/src/utils/cache-service-ref.spec.ts b/src/utils/cache-service-ref.spec.ts new file mode 100644 index 0000000..8a46e61 --- /dev/null +++ b/src/utils/cache-service-ref.spec.ts @@ -0,0 +1,72 @@ +/** + * @file cache-service-ref.spec.ts + * + * Unit tests for the CacheServiceRef singleton accessor. + * + * Tests cover: + * - get() throws with a descriptive message when called before set() + * - set() stores the instance and get() returns it + * - set() can overwrite an existing instance (hot-reload safety) + */ + +import { InMemoryCacheStore } from "@adapters/in-memory-cache-store.adapter"; + +import type { CacheModuleOptions } from "../cache-kit.module"; +import { CacheService } from "../services/cache.service"; + +import { CacheServiceRef } from "./cache-service-ref"; + +// --------------------------------------------------------------------------- +// Helper to build a minimal CacheService instance +// --------------------------------------------------------------------------- + +function makeCacheService(): CacheService { + const store = new InMemoryCacheStore(); + const options: CacheModuleOptions = { store: "memory" }; + return new (CacheService as new ( + store: InMemoryCacheStore, + options: CacheModuleOptions, + ) => CacheService)(store, options); +} + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe("CacheServiceRef", () => { + // Reset the singleton before each test by setting it back to null via set() + // We cannot set it to null directly from outside, so we reset after each test + // by storing and restoring the internal _instance via a fresh CacheService. + + it("throws a descriptive error when get() is called before set()", () => { + // Forcibly clear the internal instance by overwriting the module state + // We rely on Jest module isolation — each test file gets its own module instance + // The simplest approach: import the raw module and reset the private variable + // via the accessor itself using an indirect reset. + // Since we cannot set null directly, we rely on a fresh jest module reset. + jest.resetModules(); + const { CacheServiceRef: fresh } = jest.requireActual<{ + CacheServiceRef: typeof CacheServiceRef; + }>("./cache-service-ref"); + + // get() on an uninitialised ref must throw with a helpful message + expect(() => fresh.get()).toThrow(/CacheService is not initialised/); + }); + + it("returns the stored instance after set() is called", () => { + const service = makeCacheService(); + CacheServiceRef.set(service); + expect(CacheServiceRef.get()).toBe(service); + }); + + it("allows overwriting the instance (safe for hot-reload)", () => { + const first = makeCacheService(); + const second = makeCacheService(); + + CacheServiceRef.set(first); + CacheServiceRef.set(second); + + // get() must return the most recently set instance + expect(CacheServiceRef.get()).toBe(second); + }); +}); diff --git a/src/utils/resolve-cache-key.util.spec.ts b/src/utils/resolve-cache-key.util.spec.ts new file mode 100644 index 0000000..858e95e --- /dev/null +++ b/src/utils/resolve-cache-key.util.spec.ts @@ -0,0 +1,91 @@ +/** + * @file resolve-cache-key.util.spec.ts + * + * Unit tests for the resolveCacheKey() utility function. + * + * Tests cover: + * - Static keys (no placeholders) returned unchanged + * - Single-argument interpolation: {0} → args[0] + * - Multi-argument interpolation: {0}, {1}, {2} + * - "user:{0}" with argument 42 produces "user:42" (acceptance-criteria example) + * - Missing argument → empty string substitution + * - Non-string argument types (number, boolean, object) are coerced via String() + */ + +import { resolveCacheKey } from "./resolve-cache-key.util"; + +describe("resolveCacheKey()", () => { + // ── Static keys ─────────────────────────────────────────────────────────── + + it("returns the template unchanged when no placeholders are present", () => { + // A static key must pass through as-is + expect(resolveCacheKey("all-products", [])).toBe("all-products"); + }); + + it("returns the template unchanged when args array is empty but key is static", () => { + expect(resolveCacheKey("config:global", [])).toBe("config:global"); + }); + + // ── Single argument ─────────────────────────────────────────────────────── + + it('resolves "user:{0}" with arg "42" to "user:42" (acceptance-criteria)', () => { + // This is the exact example from the task description + expect(resolveCacheKey("user:{0}", ["42"])).toBe("user:42"); + }); + + it("substitutes a numeric argument by coercing it with String()", () => { + expect(resolveCacheKey("user:{0}", [42])).toBe("user:42"); + }); + + it("substitutes a boolean argument correctly", () => { + expect(resolveCacheKey("flag:{0}", [true])).toBe("flag:true"); + }); + + // ── Multi-argument ──────────────────────────────────────────────────────── + + it("substitutes multiple placeholders in order", () => { + expect(resolveCacheKey("post:{0}:comment:{1}", ["5", "99"])).toBe("post:5:comment:99"); + }); + + it("handles three arguments", () => { + expect(resolveCacheKey("{0}:{1}:{2}", ["a", "b", "c"])).toBe("a:b:c"); + }); + + it("uses each argument only for its own index (no cross-substitution)", () => { + // {1} must not be replaced by args[0] + expect(resolveCacheKey("x:{1}", ["first", "second"])).toBe("x:second"); + }); + + // ── Missing arguments ───────────────────────────────────────────────────── + + it("replaces a missing argument placeholder with an empty string", () => { + // {0} present but args is empty → becomes "" + expect(resolveCacheKey("key:{0}", [])).toBe("key:"); + }); + + it("replaces a placeholder for an out-of-range index with an empty string", () => { + // args[0] exists but {1} is out of range + expect(resolveCacheKey("{0}:{1}", ["only-one"])).toBe("only-one:"); + }); + + // ── Null / undefined arguments ──────────────────────────────────────────── + + it("replaces null argument with an empty string", () => { + expect(resolveCacheKey("key:{0}", [null])).toBe("key:"); + }); + + it("replaces undefined argument with an empty string", () => { + expect(resolveCacheKey("key:{0}", [undefined])).toBe("key:"); + }); + + // ── Non-trivial key shapes ──────────────────────────────────────────────── + + it("handles a repeated placeholder (same index used twice)", () => { + // Both {0} occurrences must be substituted + expect(resolveCacheKey("{0}-{0}", ["id"])).toBe("id-id"); + }); + + it("handles a key with no colons (flat namespace)", () => { + expect(resolveCacheKey("item{0}", ["7"])).toBe("item7"); + }); +});