diff --git a/package-lock.json b/package-lock.json index 2151511f16..58ebeadf52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "electron-builder": "26.4.0", "eslint": "^9.0.0", "eslint-config-prettier": "^9.0.0", + "fake-indexeddb": "6.2.5", "gulp": "^5.0.1", "gulp-babel": "^8.0.0", "gulp-clean-css": "^4.3.0", @@ -6665,6 +6666,16 @@ ], "license": "MIT" }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fancy-log": { "version": "2.0.0", "license": "MIT", diff --git a/package.json b/package.json index eb72cd3afa..ff04b841c5 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "electron-builder": "26.4.0", "eslint": "^9.0.0", "eslint-config-prettier": "^9.0.0", + "fake-indexeddb": "6.2.5", "gulp": "^5.0.1", "gulp-babel": "^8.0.0", "gulp-clean-css": "^4.3.0", diff --git a/planet/js/__tests__/CacheManager.test.js b/planet/js/__tests__/CacheManager.test.js index 7fbed0329f..52168f6419 100644 --- a/planet/js/__tests__/CacheManager.test.js +++ b/planet/js/__tests__/CacheManager.test.js @@ -6,6 +6,7 @@ // version 3 of the License, or (at your option) any later version. const CacheManager = require("../CacheManager"); +const { IDBFactory, IDBKeyRange } = require("fake-indexeddb"); // Mock IndexedDB const indexedDB = { @@ -198,14 +199,391 @@ describe("CacheManager", () => { }); }); -describe("CacheManager Integration", () => { - // These tests would require a real or mocked IndexedDB implementation - // For now, we'll skip the actual integration tests +// IndexedDB integration tests +describe("IndexedDB CacheManager integration", () => { + let cacheManager; + + beforeEach(async () => { + // fake-indexeddb v6 uses structuredClone + if (!global.structuredClone) { + global.structuredClone = val => JSON.parse(JSON.stringify(val)); + } + global.indexedDB = new IDBFactory(); + global.IDBKeyRange = IDBKeyRange; + cacheManager = new CacheManager({ + dbName: "TestCache", + metadataExpiry: 1000, + projectExpiry: 2000, + maxCacheSize: 5 + }); + await cacheManager.init(); + }); + + afterEach(() => { + cacheManager.close(); + jest.useRealTimers(); + }); + + describe("init()", () => { + let freshManager; + + beforeEach(() => { + global.indexedDB = new IDBFactory(); + global.IDBKeyRange = IDBKeyRange; + freshManager = new CacheManager({ + dbName: "InitTestCache", + metadataExpiry: 1000, + projectExpiry: 2000, + maxCacheSize: 5 + }); + }); + + afterEach(() => { + freshManager.close(); + }); + + test("returns true on success and sets isInitialized", async () => { + const result = await freshManager.init(); + expect(result).toBe(true); + expect(freshManager.isInitialized).toBe(true); + }); + + test("calls clearExpired() automatically on init", async () => { + const spy = jest.spyOn(freshManager, "clearExpired"); + await freshManager.init(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + test("returns false when window.indexedDB is missing", async () => { + const saved = global.indexedDB; + delete global.indexedDB; + const result = await freshManager.init(); + global.indexedDB = saved; + expect(result).toBe(false); + expect(freshManager.isInitialized).toBe(false); + }); + + test("second call returns true without re-opening DB", async () => { + await freshManager.init(); + const spy = jest.spyOn(freshManager, "_openDatabase"); + const result = await freshManager.init(); + expect(result).toBe(true); + expect(spy).not.toHaveBeenCalled(); + }); + + test("creates all three object stores", async () => { + await freshManager.init(); + const storeNames = freshManager.db.objectStoreNames; + expect(storeNames.contains("projectMetadata")).toBe(true); + expect(storeNames.contains("projectData")).toBe(true); + expect(storeNames.contains("projectThumbnails")).toBe(true); + }); + }); + + describe("cacheMetadata / getMetadata", () => { + test("caches and retrieves metadata by id", async () => { + const metadata = { name: "Project A", author: "ABC" }; + await cacheManager.cacheMetadata("proj-1", metadata); + expect(await cacheManager.getMetadata("proj-1")).toEqual(metadata); + }); + + test("returns null for unknown id", async () => { + expect(await cacheManager.getMetadata("nonexistent")).toBeNull(); + }); + + test("returns null for expired entry", async () => { + const BASE = 1000; + jest.useFakeTimers(); + jest.setSystemTime(BASE); + await cacheManager.cacheMetadata("proj-exp", { name: "Expired" }); + jest.setSystemTime(BASE + 1500); + expect(await cacheManager.getMetadata("proj-exp")).toBeNull(); + jest.useRealTimers(); + }); + + test("returns data for non-expired entry", async () => { + const BASE = 1000; + jest.useFakeTimers(); + jest.setSystemTime(BASE); + const metadata = { name: "Fresh" }; + await cacheManager.cacheMetadata("proj-fresh", metadata); + jest.setSystemTime(BASE + 500); + expect(await cacheManager.getMetadata("proj-fresh")).toEqual(metadata); + jest.useRealTimers(); + }); + }); + + describe("cacheProject / getProject", () => { + test("caches and retrieves project by id", async () => { + const data = { blocks: [], notes: [] }; + await cacheManager.cacheProject("p-1", data); + expect(await cacheManager.getProject("p-1")).toEqual(data); + }); + + test("returns null for unknown id", async () => { + expect(await cacheManager.getProject("unknown")).toBeNull(); + }); + + test("returns null for expired project", async () => { + const BASE = 1000; + jest.useFakeTimers(); + jest.setSystemTime(BASE); + await cacheManager.cacheProject("p-exp", { data: "old" }); + jest.setSystemTime(BASE + 2500); + expect(await cacheManager.getProject("p-exp")).toBeNull(); + jest.useRealTimers(); + }); + + test("project stored past metadataExpiry but within projectExpiry", async () => { + const BASE = 1000; + jest.useFakeTimers(); + jest.setSystemTime(BASE); + const data = { data: "alive" }; + await cacheManager.cacheProject("p-alive", data); + jest.setSystemTime(BASE + 1500); + expect(await cacheManager.getProject("p-alive")).toEqual(data); + jest.useRealTimers(); + }); + }); + + describe("cacheThumbnail / getThumbnail", () => { + test("cache and retrieves thumbnail data URL", async () => { + const url = "data:image/png;base64,abc123"; + await cacheManager.cacheThumbnail("t-1", url); + expect(await cacheManager.getThumbnail("t-1")).toBe(url); + }); + + test("cacheThumbnail returns true", async () => { + expect(await cacheManager.cacheThumbnail("t-1", "data:image/png;base64,...")).toBe( + true + ); + }); + + test("returns null for unknown id", async () => { + expect(await cacheManager.getThumbnail("unknown")).toBeNull(); + }); + + test("returns null for expired thumbnail", async () => { + const BASE = 1000; + jest.useFakeTimers(); + jest.setSystemTime(BASE); + await cacheManager.cacheThumbnail("t-exp", "data:image/png;base64..."); + jest.setSystemTime(BASE + 1500); + expect(await cacheManager.getThumbnail("t-exp")).toBeNull(); + jest.useRealTimers(); + }); + + test("getThumbnail does not update lastAccessed", async () => { + await cacheManager.cacheThumbnail("t1", "data:image/png;base64..."); + const before = await cacheManager._getFromStore("projectThumbnails", "t1"); + await cacheManager.getThumbnail("t1"); + const after = await cacheManager._getFromStore("projectThumbnails", "t1"); + expect(after.lastAccessed).toBeUndefined(); + expect(before).toEqual(after); + }); + }); - it.todo("should initialize IndexedDB database"); - it.todo("should cache and retrieve metadata"); - it.todo("should cache and retrieve projects"); - it.todo("should cache and retrieve thumbnails"); - it.todo("should clear expired entries"); - it.todo("should enforce max cache size"); + describe("clearExpired", () => { + test("returns 0 when nothing is expired", async () => { + await cacheManager.cacheMetadata("m-1", { name: "A" }); + expect(await cacheManager.clearExpired()).toBe(0); + }); + + test("removes only expired metadata", async () => { + const BASE = 1000; + jest.useFakeTimers(); + jest.setSystemTime(BASE); + await cacheManager.cacheMetadata("old", { name: "Old" }); + jest.setSystemTime(BASE + 1500); + await cacheManager.cacheMetadata("new", { name: "New" }); + jest.setSystemTime(BASE + 1600); + const cleared = await cacheManager.clearExpired(); + expect(cleared).toBe(1); + expect(await cacheManager.getMetadata("old")).toBeNull(); + expect(await cacheManager.getMetadata("new")).toEqual({ name: "New" }); + jest.useRealTimers(); + }); + + test("removes only expired projects", async () => { + const BASE = 1000; + jest.useFakeTimers(); + jest.setSystemTime(BASE); + await cacheManager.cacheProject("old-p", { data: "old" }); + jest.setSystemTime(BASE + 2500); + await cacheManager.cacheProject("new-p", { data: "new" }); + jest.setSystemTime(BASE + 2600); + const cleared = await cacheManager.clearExpired(); + expect(cleared).toBe(1); + expect(await cacheManager.getProject("old-p")).toBeNull(); + expect(await cacheManager.getProject("new-p")).toEqual({ data: "new" }); + jest.useRealTimers(); + }); + + test("clears from all three stores. Returns 3 when all three entries are expired", async () => { + const BASE = 1000; + jest.useFakeTimers(); + jest.setSystemTime(BASE); + await cacheManager.cacheMetadata("m-all", { name: "M" }); + await cacheManager.cacheProject("p-all", { data: "P" }); + await cacheManager.cacheThumbnail("t-all", "data:image/png;base64,..."); + jest.setSystemTime(BASE + 2500); + const cleared = await cacheManager.clearExpired(); + expect(cleared).toBe(3); + jest.useRealTimers(); + }); + + test("returns 0 after clearAll()", async () => { + await cacheManager.cacheMetadata("m-1", { name: "A" }); + await cacheManager.clearAll(); + expect(await cacheManager.clearExpired()).toBe(0); + }); + }); + + describe("clearAll", () => { + test("clearAll returns true", async () => { + expect(await cacheManager.clearAll()).toBe(true); + }); + + test("getStats returns all zeros after clearing", async () => { + await cacheManager.cacheMetadata("m-1", { name: "A" }); + await cacheManager.cacheProject("p-1", { data: "B" }); + await cacheManager.clearAll(); + expect(await cacheManager.getStats()).toEqual({ + metadata: 0, + projects: 0, + thumbnails: 0 + }); + }); + + test("cached items are inaccessible after clearing", async () => { + await cacheManager.cacheMetadata("m-1", { name: "A" }); + await cacheManager.cacheProject("p-1", { data: "B" }); + await cacheManager.cacheThumbnail("t-1", "data:..."); + await cacheManager.clearAll(); + expect(await cacheManager.getMetadata("m-1")).toBeNull(); + expect(await cacheManager.getProject("p-1")).toBeNull(); + expect(await cacheManager.getThumbnail("t-1")).toBeNull(); + }); + + test("calling twice returns true both times", async () => { + expect(await cacheManager.clearAll()).toBe(true); + expect(await cacheManager.clearAll()).toBe(true); + }); + }); + + describe("getStats", () => { + test("returns all zeros after init", async () => { + expect(await cacheManager.getStats()).toEqual({ + metadata: 0, + projects: 0, + thumbnails: 0 + }); + }); + + test("returns accurate counts after caching items", async () => { + await cacheManager.cacheMetadata("m-1", { name: "A" }); + await cacheManager.cacheMetadata("m-2", { name: "B" }); + await cacheManager.cacheProject("p-1", { data: "C" }); + await cacheManager.cacheThumbnail("t-1", "data:..."); + expect(await cacheManager.getStats()).toEqual({ + metadata: 2, + projects: 1, + thumbnails: 1 + }); + }); + + test("returns all zeros after clearAll()", async () => { + await cacheManager.cacheMetadata("m-1", { name: "A" }); + await cacheManager.clearAll(); + expect(await cacheManager.getStats()).toEqual({ + metadata: 0, + projects: 0, + thumbnails: 0 + }); + }); + + test("re-caching same id does not double-count", async () => { + await cacheManager.cacheMetadata("m-1", { name: "A" }); + await cacheManager.cacheMetadata("m-1", { name: "B" }); + expect((await cacheManager.getStats()).metadata).toBe(1); + }); + + test("counts expired entries", async () => { + const BASE = 1000; + jest.useFakeTimers(); + jest.setSystemTime(BASE); + await cacheManager.cacheMetadata("m-exp", { name: "Expired" }); + jest.setSystemTime(BASE + 1500); + expect(await cacheManager.getMetadata("m-exp")).toBeNull(); + expect((await cacheManager.getStats()).metadata).toBe(1); + jest.useRealTimers(); + }); + }); + + describe("enforceMaxSize", () => { + test("count stays at maxCacheSize after insertion beyond max", async () => { + for (let i = 0; i < 7; i++) { + await cacheManager.cacheMetadata(`m-${i}`, { name: `Item ${i}` }); + } + expect((await cacheManager.getStats()).metadata).toBe(5); + }); + + test("does not remove when count is below maxCacheSize", async () => { + for (let i = 0; i < 3; i++) { + await cacheManager.cacheMetadata(`m-${i}`, { name: `Item ${i}` }); + } + expect((await cacheManager.getStats()).metadata).toBe(3); + }); + + test("LRU removal: oldest lastAccessed is removed first on overflow", async () => { + const BASE = 1000; + jest.useFakeTimers(); + for (let i = 0; i < 5; i++) { + jest.setSystemTime(BASE + i * 100); + await cacheManager.cacheMetadata(`m-${i}`, { name: `Item ${i}` }); + } + jest.setSystemTime(BASE + 1000); + await cacheManager.cacheMetadata("m-5", { name: "Item 5" }); + expect((await cacheManager.getStats()).metadata).toBe(5); + const isRemoved = await cacheManager._getFromStore("projectMetadata", "m-0"); + expect(isRemoved).toBeUndefined(); + jest.useRealTimers(); + }); + }); + + describe("updateLastAccessed", () => { + test("getMetadata updates lastAccessed", async () => { + const BASE = 1000; + jest.useFakeTimers(); + jest.setSystemTime(BASE); + await cacheManager.cacheMetadata("m-ua", { name: "Test" }); + const before = await cacheManager._getFromStore("projectMetadata", "m-ua"); + jest.setSystemTime(BASE + 500); + await cacheManager.getMetadata("m-ua"); + const after = await cacheManager._getFromStore("projectMetadata", "m-ua"); + expect(before.lastAccessed).toBe(BASE); + expect(after.lastAccessed).toBe(BASE + 500); + jest.useRealTimers(); + }); + + test("does not throw for non-existent key", async () => { + await expect( + cacheManager._updateLastAccessed("projectMetadata", "nonexistent") + ).resolves.not.toThrow(); + }); + + test("getProject updates lastAccessed", async () => { + const BASE = 1000; + jest.useFakeTimers(); + jest.setSystemTime(BASE); + await cacheManager.cacheProject("p-ua", { data: "test" }); + const before = await cacheManager._getFromStore("projectData", "p-ua"); + jest.setSystemTime(BASE + 500); + await cacheManager.getProject("p-ua"); + const after = await cacheManager._getFromStore("projectData", "p-ua"); + expect(before.lastAccessed).toBe(BASE); + expect(after.lastAccessed).toBe(BASE + 500); + jest.useRealTimers(); + }); + }); });