diff --git a/package-lock.json b/package-lock.json index f09dd33..125953b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,22 @@ { - "name": "react-local-fetch", - "version": "1.0.0", + "name": "@thinkgrid/react-local-fetch", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "react-local-fetch", - "version": "1.0.0", + "name": "@thinkgrid/react-local-fetch", + "version": "0.1.0", "license": "MIT", "dependencies": { "idb-keyval": "^6.2.2" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "fake-indexeddb": "^6.2.5", "jsdom": "^29.0.1", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -26,6 +29,13 @@ "react-dom": ">=16.8" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@asamuzakjp/css-color": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", @@ -67,6 +77,43 @@ "dev": true, "license": "MIT" }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -1399,6 +1446,82 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1410,6 +1533,14 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1581,6 +1712,31 @@ "node": ">=0.4.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1588,6 +1744,16 @@ "dev": true, "license": "MIT" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1708,6 +1874,13 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1754,6 +1927,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1764,6 +1947,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -1846,6 +2037,16 @@ "node": ">=12.0.0" } }, + "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/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1910,6 +2111,16 @@ "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==", "license": "Apache-2.0" }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -1927,6 +2138,14 @@ "node": ">=10" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/jsdom": { "version": "29.0.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", @@ -2269,6 +2488,17 @@ "node": "20 || >=22" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2286,6 +2516,16 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -2492,6 +2732,22 @@ } } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2525,6 +2781,14 @@ "react": "^19.2.4" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -2539,6 +2803,20 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2699,6 +2977,19 @@ "dev": true, "license": "MIT" }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", diff --git a/package.json b/package.json index bdf84fa..d039b1c 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,11 @@ "idb-keyval": "^6.2.2" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "fake-indexeddb": "^6.2.5", "jsdom": "^29.0.1", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -33,4 +36,4 @@ "typescript": "^5.9.3", "vitest": "^4.1.0" } -} \ No newline at end of file +} diff --git a/src/integration.test.ts b/src/integration.test.ts new file mode 100644 index 0000000..3d28bdb --- /dev/null +++ b/src/integration.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import 'fake-indexeddb/auto'; +import { localFetch } from './fetcher'; +import { clearAllStorage, getFromStorage } from './storage'; + +// Mock fetch +vi.stubGlobal('fetch', vi.fn()); + +describe('Integration: localFetch full flow', () => { + const url = 'https://api.example.com/data'; + const secret = 'test-secret-key-1234567890123456'; // 32 chars for AES-GCM + const mockData = { message: 'Integration test success!', timestamp: Date.now() }; + + beforeEach(async () => { + vi.clearAllMocks(); + await clearAllStorage(); + }); + + it('should fetch from network, encrypt, and store in IndexedDB on first call', async () => { + (fetch as any).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockData), + }); + + const result = await localFetch(url, { + key: 'integration-test', + encrypt: true, + secret, + }); + + expect(result).toEqual(mockData); + expect(fetch).toHaveBeenCalledTimes(1); + + // Verify it was stored in IndexedDB (encrypted) + const stored = await getFromStorage('integration-test'); + expect(stored).toBeDefined(); + expect(stored?.metadata.isEncrypted).toBe(true); + expect(stored?.data).toBeDefined(); + // In some environments it might be a Uint8Array or ArrayBuffer + expect((stored?.data as any).byteLength).toBeGreaterThan(0); + expect(stored?.metadata.salt).toBeDefined(); + expect(stored?.metadata.iv).toBeDefined(); + }); + + it('should retrieve from IndexedDB and decrypt on subsequent call', async () => { + // 1. First call to populate cache + (fetch as any).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockData), + }); + + await localFetch(url, { + key: 'integration-test-2', + encrypt: true, + secret, + }); + + vi.clearAllMocks(); + + // 2. Second call should hit the cache + // We mock fetch for the background revalidation, but it shouldn't block the return + (fetch as any).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ ...mockData, revalidated: true }), + }); + + const result = await localFetch(url, { + key: 'integration-test-2', + encrypt: true, + secret, + ttl: 3600, // Valid for an hour + }); + + expect(result).toEqual(mockData); // Should return cached data + expect(fetch).toHaveBeenCalled(); // Background revalidation still happens + }); + + it('should handle version mismatch by clearing cache and fetching fresh', async () => { + // 1. Populate cache with version 1 + (fetch as any).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ version: 1 }), + }); + + await localFetch(url, { + key: 'version-test', + version: 1, + }); + + vi.clearAllMocks(); + + // 2. Request with version 2 + const newData = { version: 2 }; + (fetch as any).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(newData), + }); + + const result = await localFetch(url, { + key: 'version-test', + version: 2, + }); + + expect(result).toEqual(newData); + expect(fetch).toHaveBeenCalled(); + + // Verify cache updated to version 2 + const stored = await getFromStorage('version-test'); + expect(stored?.metadata.version).toBe(2); + }); +}); diff --git a/src/storage.test.ts b/src/storage.test.ts new file mode 100644 index 0000000..ba1fc43 --- /dev/null +++ b/src/storage.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import 'fake-indexeddb/auto'; +import { saveToStorage, getFromStorage, removeFromStorage, clearAllStorage } from './storage'; + +describe('storage utility', () => { + const testKey = 'test-key'; + const testData = { id: 1, name: 'Sample Data' }; + const testEntry = { + data: testData, + metadata: { + timestamp: Date.now(), + version: 1, + isEncrypted: false, + }, + }; + + beforeEach(async () => { + await clearAllStorage(); + }); + + it(' should save and retrieve data correctly', async () => { + await saveToStorage(testKey, testEntry); + const retrieved = await getFromStorage(testKey); + expect(retrieved).toEqual(testEntry); + }); + + it('should return undefined for non-existent key', async () => { + const retrieved = await getFromStorage('non-existent'); + expect(retrieved).toBeUndefined(); + }); + + it('should remove data correctly', async () => { + await saveToStorage(testKey, testEntry); + await removeFromStorage(testKey); + const retrieved = await getFromStorage(testKey); + expect(retrieved).toBeUndefined(); + }); + + it('should clear all data', async () => { + await saveToStorage('key1', testEntry); + await saveToStorage('key2', testEntry); + await clearAllStorage(); + + expect(await getFromStorage('key1')).toBeUndefined(); + expect(await getFromStorage('key2')).toBeUndefined(); + }); + + it('should handle undefined window gracefully (SSR simulation)', async () => { + // This is a bit tricky to test because vitest/jsdom might already have window defined. + // But we can check if the functions don't throw if we temporarily "hide" window if possible. + // Or just rely on the code check if (typeof window === 'undefined') return; + + const originalWindow = globalThis.window; + // @ts-ignore + delete globalThis.window; + + expect(await getFromStorage(testKey)).toBeUndefined(); + await saveToStorage(testKey, testEntry); // should not throw + + // Restore window + globalThis.window = originalWindow; + }); +}); diff --git a/src/useLocalFetch.test.ts b/src/useLocalFetch.test.ts new file mode 100644 index 0000000..499e9a6 --- /dev/null +++ b/src/useLocalFetch.test.ts @@ -0,0 +1,103 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useLocalFetch } from './useLocalFetch'; +import * as fetcherModule from './fetcher'; + +// Mock the fetcher module +vi.mock('./fetcher', () => ({ + localFetch: vi.fn(), +})); + +describe('useLocalFetch hook', () => { + const url = 'https://api.example.com/data'; + const options = { key: 'test-key' }; + const mockData = { id: 1, name: 'Test Data' }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should start in loading state', async () => { + (fetcherModule.localFetch as any).mockResolvedValue(mockData); + + // Use a slow promise to ensure we can see loading state + let resolveMock: any; + const slowPromise = new Promise((resolve) => { + resolveMock = resolve; + }); + (fetcherModule.localFetch as any).mockReturnValue(slowPromise); + + const { result } = renderHook(() => useLocalFetch(url, options)); + + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBeNull(); + expect(result.current.error).toBeNull(); + + await act(async () => { + resolveMock(mockData); + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toEqual(mockData); + }); + + it('should handle success state correctly', async () => { + (fetcherModule.localFetch as any).mockResolvedValue(mockData); + + const { result } = renderHook(() => useLocalFetch(url, options)); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toEqual(mockData); + expect(result.current.error).toBeNull(); + }); + + it('should handle error state correctly', async () => { + const errorMsg = 'Failed to fetch'; + (fetcherModule.localFetch as any).mockRejectedValue(new Error(errorMsg)); + + const { result } = renderHook(() => useLocalFetch(url, options)); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toBeNull(); + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toBe(errorMsg); + }); + + it('should revalidate when called', async () => { + (fetcherModule.localFetch as any).mockResolvedValue(mockData); + + const { result } = renderHook(() => useLocalFetch(url, options)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const newData = { id: 2, name: 'New Data' }; + (fetcherModule.localFetch as any).mockResolvedValue(newData); + + await act(async () => { + await result.current.revalidate(); + }); + + expect(result.current.data).toEqual(newData); + expect(fetcherModule.localFetch).toHaveBeenCalledTimes(2); + }); + + it('should re-fetch when options or URL change', async () => { + (fetcherModule.localFetch as any).mockResolvedValue(mockData); + + const { rerender, result } = renderHook( + ({ u, o }) => useLocalFetch(u, o), + { + initialProps: { u: url, o: options }, + } + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(fetcherModule.localFetch).toHaveBeenCalledTimes(1); + + const newUrl = 'https://api.example.com/other'; + rerender({ u: newUrl, o: options }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(fetcherModule.localFetch).toHaveBeenCalledTimes(2); + expect(fetcherModule.localFetch).toHaveBeenCalledWith(newUrl, options); + }); +});