diff --git a/package-lock.json b/package-lock.json index 4c89843..c21d0d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "devDependencies": { "@brightspace-ui/testing": "^1", "eslint": "^8", - "eslint-config-brightspace": "^1" + "eslint-config-brightspace": "^1", + "sinon": "^18.0.0" } }, "node_modules/@ampproject/remapping": { @@ -1236,6 +1237,50 @@ "win32" ] }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", + "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz", + "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^2.0.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "node_modules/@sinonjs/samsam/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", + "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", + "dev": true + }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", @@ -5775,6 +5820,12 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true + }, "node_modules/keygrip": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", @@ -6218,6 +6269,12 @@ "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "dev": true }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -6728,6 +6785,25 @@ "node": ">= 0.4.0" } }, + "node_modules/nise": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.0.0.tgz", + "integrity": "sha512-K8ePqo9BFvN31HXwEtTNGzgrPpmvgciDsFz8aztFjt4LqKO/JeFD8tBOeuDiCMXrIl/m1YvfH8auSpxfaD09wg==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/text-encoding": "^0.7.2", + "just-extend": "^6.2.0", + "path-to-regexp": "^6.2.1" + } + }, + "node_modules/nise/node_modules/path-to-regexp": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz", + "integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==", + "dev": true + }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -7981,6 +8057,45 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sinon": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-18.0.0.tgz", + "integrity": "sha512-+dXDXzD1sBO6HlmZDd7mXZCR/y5ECiEiGCBSGuFD/kZ0bDTofPYc6JaeGmPSF+1j1MejGUWkORbYOLDyvqCWpA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.2.0", + "nise": "^6.0.0", + "supports-color": "^7" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/sinon/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -8510,6 +8625,15 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", diff --git a/package.json b/package.json index d45e8dc..87ea125 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "devDependencies": { "@brightspace-ui/testing": "^1", "eslint": "^8", - "eslint-config-brightspace": "^1" + "eslint-config-brightspace": "^1", + "sinon": "^18.0.0" }, "dependencies": { "@formatjs/intl-pluralrules": "^1", diff --git a/test/getLocalizeResources.test.js b/test/getLocalizeResources.test.js new file mode 100644 index 0000000..4362d05 --- /dev/null +++ b/test/getLocalizeResources.test.js @@ -0,0 +1,449 @@ +import { __clearWindowCache, getLocalizeOverrideResources } from '../helpers/getLocalizeResources.js'; +import { expect } from '@brightspace-ui/testing'; +import { getDocumentLocaleSettings } from '../lib/common.js'; +import sinon from 'sinon'; + +const DefaultsGreek = { txtOne: 'One (el)', txtTwo: 'Two (el)' }; +const DefaultsEnglish = { txtOne: 'One (en)', txtTwo: 'Two (en)' }; +const Overrides = { txtTwo: 'Two (override)' }; +const ResourceOverrides = 'd2l-package\\some-component'; +const UrlBatch = 'http://lms/path/to/batch'; +const UrlCollection = 'http://lms/path/to/collection'; +const VersionNext = 'W\\"abc124"'; +const VersionPrev = 'W\\"abc123"'; + +const OsloBatch = { batch: UrlBatch, collection: UrlCollection, version: VersionPrev }; +const OsloDisabled = { batch: null, collection: null, version: null }; +const OsloSingle = { batch: null, collection: UrlCollection, version: null }; +const UrlOverrides = `${UrlCollection}${ResourceOverrides}`; + +function formatFunc() { + return 'd2l-package\\some-component'; +} + +describe('getLocalizeResources', () => { + + const documentLocaleSettings = getDocumentLocaleSettings(); + + if (documentLocaleSettings.oslo === undefined) { + documentLocaleSettings.oslo = Object.assign({}, OsloDisabled); + } + + before(() => { + // Chrome/Safari only expose over HTTPS, + // therefore window.caches and window.CacheStorage may be undefined + if (!('caches' in window)) { + Object.defineProperty(window, 'caches', { + configurable: true, + get: function() { return undefined;} + }); + } + if (!('CacheStorage' in window)) { + Object.defineProperty(window, 'CacheStorage', { + configurable: true, + get: function() { return undefined;} + }); + } + }); + + afterEach(() => { + + sinon.restore(); + __clearWindowCache(); + }); + + it('does not fetch overrides when oslo is disabled', async() => { + + sinon.stub(documentLocaleSettings, 'oslo').get(() => OsloDisabled); + + const fetchStub = sinon.stub(window, 'fetch'); + const formatFuncSpy = sinon.spy(formatFunc); + + const expected = { + language: 'en', + resources: DefaultsEnglish + }; + + const actual = await getLocalizeOverrideResources( + 'en', + DefaultsEnglish, + formatFuncSpy + ); + + expect(formatFuncSpy).to.have.not.been.called; + expect(fetchStub).to.have.not.been.called; + expect(actual).to.deep.equal(expected); + }); + + it('fetches single overrides when oslo enabled', async() => { + + sinon.stub(documentLocaleSettings, 'oslo').get(() => OsloSingle); + + const fetchStub = sinon.stub(window, 'fetch'); + const formatFuncSpy = sinon.spy(formatFunc); + + fetchStub.resolves({ + ok: true, + json() { + return Promise.resolve( + Overrides + ); + } + }); + + const expected = { + language: 'el', + resources: Object.assign({}, DefaultsGreek, Overrides) + }; + + const actual = await getLocalizeOverrideResources( + 'el', + DefaultsGreek, + formatFuncSpy + ); + + expect(formatFuncSpy).to.have.been.callCount(1); // 1 lms + expect(formatFuncSpy).to.have.been.calledWithExactly(); + expect(fetchStub).to.have.been.called; + expect(actual).to.deep.equal(expected); + }); + + it('doesnt cache single overrides', async() => { + + sinon.stub(documentLocaleSettings, 'oslo').get(() => OsloSingle); + + const fetchStub = sinon.stub(window, 'fetch'); + const formatFuncSpy = sinon.spy(formatFunc); + + const expected = { + language: 'el', + resources: Object.assign({}, DefaultsGreek, Overrides) + }; + + fetchStub.resolves({ + ok: true, + json() { + return Promise.resolve( + Overrides + ); + } + }); + + // first call to prime cache - discarded + await getLocalizeOverrideResources( + 'el', + DefaultsGreek, + formatFuncSpy + ); + + expect(formatFuncSpy).to.have.been.callCount(1); // 1 lms + expect(fetchStub).to.have.been.callCount(1); + + formatFuncSpy.resetHistory(); + fetchStub.resetHistory(); + + const actual = await getLocalizeOverrideResources( + 'el', + DefaultsGreek, + formatFuncSpy + ); + + expect(formatFuncSpy).to.have.been.callCount(1); // 1 lms + expect(fetchStub).to.have.been.callCount(0); + expect(actual).to.deep.equal(expected); + }); + + it('fetches batch overrides when enabled, and caches them', async() => { + + const config = Object.assign({}, OsloBatch); // version gets mutated + sinon.stub(documentLocaleSettings, 'oslo').get(() => config); + + const cache = new Map(); + + const cacheFake = { + match(request) { + + const response = cache.get(request.url); + return Promise.resolve(response && response.clone()); + }, + put(request, response) { + + cache.set(request.url, response); + return Promise.resolve(); + } + }; + + const cacheStorageFake = { + open() { + return Promise.resolve(cacheFake); + } + }; + + sinon.replaceGetter(window, 'caches', () => cacheStorageFake); + + const fetchStub = sinon.stub(window, 'fetch'); + const formatFuncSpy = sinon.spy(formatFunc); + const matchSpy = sinon.spy(cacheFake, 'match'); + const openSpy = sinon.spy(cacheStorageFake, 'open'); + const putSpy = sinon.spy(cacheFake, 'put'); + + fetchStub.resolves({ + ok: true, + json() { + return Promise.resolve({ + resources: [ + { + status: 200, + headers: [ + ['Content-Type', 'application/json'], + ['ETag', VersionNext], + ], + json() { + () => { + Overrides; + }; + }, + body: JSON.stringify(Overrides) + } + ] + }); + } + }); + + const expected = { + language: 'el', + resources: Object.assign({}, DefaultsGreek, Overrides) + }; + + // Stage 1: novel request + + let actual = await getLocalizeOverrideResources( + 'el', + DefaultsGreek, + formatFuncSpy + ); + + expect(formatFuncSpy).to.have.been.callCount(1); // 1 lms + expect(formatFuncSpy).to.have.been.calledWithExactly(); + expect(openSpy).to.always.have.been.calledWithExactly('d2l-oslo'); + expect(openSpy).to.have.callCount(3); + expect(matchSpy).to.have.been.callCount(1); + expect(matchSpy).to.have.been.calledWithMatch(new Request(UrlOverrides)); + expect(fetchStub).to.have.been.calledOnceWithExactly(UrlBatch, { + method: 'POST', + body: JSON.stringify({ + resources: [ResourceOverrides] + }), + headers: { + 'Content-Type': 'application/json' + } + }); + expect(putSpy).to.have.been.callCount(1); + expect(actual).to.deep.equal(expected); + + fetchStub.resetHistory(); + formatFuncSpy.resetHistory(); + matchSpy.resetHistory(); + openSpy.resetHistory(); + putSpy.resetHistory(); + + // Stage 2: window cache + + actual = await getLocalizeOverrideResources( + 'el', + DefaultsGreek, + formatFuncSpy + ); + + expect(formatFuncSpy).to.have.been.callCount(1); // 1 cache key + expect(formatFuncSpy).to.have.been.calledWithExactly(); + expect(openSpy).to.always.have.been.calledWithExactly('d2l-oslo'); // in the window cache + expect(openSpy).to.have.callCount(2); + expect(matchSpy).to.have.not.been.called; + expect(fetchStub).to.have.not.been.called; + expect(putSpy).to.have.not.been.called; + expect(actual).to.deep.equal(expected); + + // Stage 3: CacheStorage cache + + __clearWindowCache(); + fetchStub.resetHistory(); + formatFuncSpy.resetHistory(); + matchSpy.resetHistory(); + openSpy.resetHistory(); + putSpy.resetHistory(); + + actual = await getLocalizeOverrideResources( + 'el', + DefaultsGreek, + formatFuncSpy + ); + + expect(formatFuncSpy).to.have.been.callCount(1); // 1 cache key + expect(formatFuncSpy).to.have.been.calledWithExactly(); + expect(openSpy).to.have.been.calledWithExactly('d2l-oslo'); + expect(openSpy).to.have.callCount(3); + expect(matchSpy).to.have.been.callCount(1); + expect(matchSpy).to.have.been.calledWithMatch(new Request(UrlOverrides)); + expect(fetchStub).to.have.not.been.called; // worker updated version with NextVersion + expect(putSpy).to.have.not.been.called; + expect(actual).to.deep.equal(expected); + }); + + it('rejects individually when batch sub-request fails, caches failures', async() => { + + sinon.stub(documentLocaleSettings, 'oslo').get(() => OsloBatch); + + const cacheFake = { + match() { + return Promise.resolve(); + }, + put() { + return Promise.resolve(); + } + }; + + const cacheStorageFake = { + open() { + return Promise.resolve(cacheFake); + } + }; + + sinon.replaceGetter(window, 'caches', () => cacheStorageFake); + + let counter = 0; + + sinon.replace(URL, 'createObjectURL', () => `blob://fake-${++counter}`); + + const fetchStub = sinon.stub(window, 'fetch'); + const formatFuncSpy = sinon.spy(formatFunc); + const putSpy = sinon.spy(cacheFake, 'put'); + + fetchStub.resolves({ + ok: true, + json() { + return Promise.resolve({ + resources: [ + { + status: 404, + headers: [], + body: '' + } + ] + }); + } + }); + + const expected = { + language: 'el', + resources: DefaultsGreek + }; + + let actual = await getLocalizeOverrideResources( + 'el', + DefaultsGreek, + formatFuncSpy + ); + + expect(formatFuncSpy).to.have.been.callCount(1); // 1 lms + expect(formatFuncSpy).to.have.been.calledWithExactly(); + expect(fetchStub).to.have.been.calledOnce; + expect(fetchStub).to.have.been.calledWithExactly(UrlBatch, { + method: 'POST', + body: JSON.stringify({ + resources: [ResourceOverrides] + }), + headers: { + 'Content-Type': 'application/json' + } + }); + expect(putSpy).to.have.been.callCount(1); + expect(actual).to.deep.equal(expected); + + formatFuncSpy.resetHistory(); + fetchStub.resetHistory(); + putSpy.resetHistory(); + + actual = await getLocalizeOverrideResources( + 'el', + DefaultsGreek, + formatFuncSpy + ); + + expect(formatFuncSpy).to.have.been.callCount(1); // 1 cache key + expect(formatFuncSpy).to.have.been.calledWithExactly(); + expect(fetchStub).to.have.not.been.called; + expect(putSpy).to.have.not.been.called; + expect(actual).to.deep.equal(expected); + }); + + it('rejects everything when batch request', async() => { + + sinon.stub(documentLocaleSettings, 'oslo').get(() => OsloBatch); + + const cacheFake = { + match() { + return Promise.resolve(); + }, + put() { + return Promise.resolve(); + } + }; + + const cacheStorageFake = { + open() { + return Promise.resolve(cacheFake); + } + }; + + sinon.replaceGetter(window, 'caches', () => cacheStorageFake); + + const fetchStub = sinon.stub(window, 'fetch'); + const formatFuncSpy = sinon.spy(formatFunc); + const putSpy = sinon.spy(cacheFake, 'put'); + + fetchStub.resolves({ ok: false }); + + const expected = { + language: 'el', + resources: DefaultsGreek + }; + + let actual = await getLocalizeOverrideResources( + 'el', + DefaultsGreek, + formatFuncSpy + ); + + expect(formatFuncSpy).to.have.been.callCount(1); // 1 lms + expect(formatFuncSpy).to.have.been.calledWithExactly(); + expect(fetchStub).to.have.been.calledOnce; + expect(fetchStub).to.have.been.calledWithExactly(UrlBatch, { + method: 'POST', + body: JSON.stringify({ + resources: [ResourceOverrides] + }), + headers: { + 'Content-Type': 'application/json' + } + }); + expect(putSpy).to.have.not.been.called; + expect(actual).to.deep.equal(expected); + + formatFuncSpy.resetHistory(); + fetchStub.resetHistory(); + putSpy.resetHistory(); + + actual = await getLocalizeOverrideResources( + 'el', + DefaultsGreek, + formatFuncSpy + ); + + expect(formatFuncSpy).to.have.been.callCount(1); // 1 cache key + expect(formatFuncSpy).to.have.been.calledWithExactly(); + expect(fetchStub).to.have.not.been.called; + expect(putSpy).to.have.not.been.called; + expect(actual).to.deep.equal(expected); + }); +});