diff --git a/packages/operators/src/request/autoPagination.js b/packages/operators/src/request/autoPagination.js index 94b2512..345ac5a 100644 --- a/packages/operators/src/request/autoPagination.js +++ b/packages/operators/src/request/autoPagination.js @@ -5,7 +5,7 @@ import { request } from './request'; export const autoPagination = ({ resolveRoute }) => { return source => source.pipe( - concatMap(({ url }) => from(resolveRoute(url)).pipe(request(), getNext(resolveRoute, url))), + concatMap(url => from(resolveRoute(url)).pipe(request(), getNext(resolveRoute, url))), map(resp => resp.clone()) ); }; diff --git a/packages/operators/src/request/autoPagination.test.js b/packages/operators/src/request/autoPagination.test.js index c14e81a..25c78f9 100644 --- a/packages/operators/src/request/autoPagination.test.js +++ b/packages/operators/src/request/autoPagination.test.js @@ -1,18 +1,99 @@ -import { concatAll, map, of } from 'rxjs'; -import { beforeEach, describe, expect, test } from 'vitest'; +import { concatAll, concatMap, delay, from, map, of, toArray } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { log } from '../log'; -import { autoPagination } from './autoPagination'; import { resolveJSON } from './response'; -describe('auto pagination', function () { +describe('auto pagination - mocked', function () { + const testScheduler = new TestScheduler((actual, expected) => { + expect(actual).to.eql(expected); + }); + beforeEach(function () { - // + vi.doMock('./request', importOriginal => ({ + request: () => source => source.pipe(concatMap(({ v, t }) => of(v).pipe(delay(t)))) + })); + + Object.prototype.clone = vi.fn(); + vi.spyOn(Object.prototype, 'clone').mockImplementation(function (e) { + return { ...JSON.parse(JSON.stringify(this)) }; + }); }); - test('auto pagination', async function () { + afterEach(() => { + vi.doUnmock('./request'); + }); + + test('classic testing', async () => { + const { autoPagination } = await import('./autoPagination'); + + const triggerValues = { + a: { t: 2, v: { value: 'a', next: 1 } }, + b: { t: 5, v: { value: 'b', next: 2 } }, + c: { t: 3, v: { value: 'c', next: 3 } }, + d: { t: 1, v: { value: 'd', next: 4 } }, + e: { t: 4, v: { value: 'e', next: null } } + }; + + const expectedVal = Array.from(Object.entries(triggerValues)).map(([k, { v }]) => v); + + const triggerVal = Object.values(triggerValues); + await new Promise((done, error) => { + of(triggerVal[0]) + .pipe( + autoPagination({ + resolveRoute: (conf, resp) => + ((!resp || resp.next) && [triggerVal[resp?.next || 0]]) || [] + }), + toArray() + ) + .subscribe({ + next: e => expect(e).toStrictEqual(expectedVal), + complete: () => done(), + error: () => error() + }); + }); + }); + + test.skip('marble testing', async () => { + const { autoPagination } = await import('./autoPagination'); + + const triggerVal = { + a: { t: 2, v: { value: 'a', next: 'b' } }, + b: { t: 5, v: { value: 'b', next: 'c' } }, + c: { t: 3, v: { value: 'c', next: 'd' } }, + d: { t: 1, v: { value: 'd', next: 'e' } }, + e: { t: 4, v: { value: 'e', next: null } } + }; + + const expectedVal = Object.fromEntries( + Array.from(Object.entries(triggerVal)).map(([k, { v }]) => [k, v]) + ); + + testScheduler.run(({ cold, expectObservable }) => { + expectObservable( + cold('-a-------------------', triggerVal).pipe( + autoPagination({ + resolveRoute: (conf, resp) => + ((!resp || resp.next) && [triggerVal[resp?.next || 'a']]) || [] + }) + ) + ).toBe('---a----b--cd---e----', expectedVal); + }); + }); +}); + +describe.skip('auto pagination - demo', function () { + beforeEach(function () { + vi.resetModules(); + }); + + test('sample testing', async function () { + const { autoPagination } = await import('./autoPagination'); + return new Promise(done => { - return of({ url: new URL('https://dummyjson.com/products') }) + return of(new URL('https://dummyjson.com/products')) .pipe( autoPagination({ resolveRoute: async (url, resp) => { diff --git a/packages/operators/src/request/cache.js b/packages/operators/src/request/cache.js index a0f6e01..d5300db 100644 --- a/packages/operators/src/request/cache.js +++ b/packages/operators/src/request/cache.js @@ -1,4 +1,4 @@ -import { ReplaySubject, share, timer } from 'rxjs'; +import { ReplaySubject, share, tap, timer } from 'rxjs'; export const cache = ttl => { return source => @@ -6,7 +6,9 @@ export const cache = ttl => { share({ // TODO: check if a buffer size is neccessary connector: () => new ReplaySubject(), - resetOnComplete: () => timer(ttl) + // resetOnError: false, + resetOnComplete: () => timer(ttl), + resetOnRefCountZero: false }) ); }; diff --git a/packages/operators/src/request/cache.test.js b/packages/operators/src/request/cache.test.js index 8d7a436..f2835ea 100644 --- a/packages/operators/src/request/cache.test.js +++ b/packages/operators/src/request/cache.test.js @@ -1,21 +1,54 @@ import fetchMock from 'fetch-mock'; -import { defer, of, tap } from 'rxjs'; +import { defer, delay, from, interval, map, mapTo, of, tap, throttleTime } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import { cache } from './cache'; import { requestText } from './request'; +describe('cache - mocked', function () { + beforeEach(function () { + // + }); + + afterEach(function () { + // + }); + + test.skip('cache resetted after 100ms', async function () { + let counter = 0; + const a = of(counter).pipe( + tap(e => console.log('U', e)), + cache(5) + ); + + defer(() => a) + .pipe(delay(2)) + .subscribe(e => console.log(e)); + defer(() => a) + .pipe(delay(2)) + .subscribe(e => console.log(e)); + + await new Promise(done => setTimeout(done), 500); + + defer(() => a) + .pipe(delay(100)) + .subscribe(e => console.log(e)); + + await new Promise(done => setTimeout(done), 1000); + }); +}); + describe('cache', function () { beforeEach(function () { let counter = 0; fetchMock.mockGlobal().get( 'https://httpbin.org/my-url-fast', - () => { - return new Response(++counter, { + () => + new Response(++counter, { status: 200, headers: { 'Content-type': 'plain/text' } - }); - }, + }), { delay: 0, repeat: 2 } ); }); @@ -25,7 +58,11 @@ describe('cache', function () { }); test('cache resetted after 100ms', async function () { - const a = of('https://httpbin.org/my-url-fast').pipe(requestText(), cache(1000)); + const a = of('https://httpbin.org/my-url-fast').pipe( + requestText(), + tap(() => console.log('CHECK')), + cache(1000) + ); await new Promise(done => { a.subscribe({ next: e => expect(e).toBe('1'), diff --git a/packages/operators/src/request/concurrentRequest.js b/packages/operators/src/request/concurrentRequest.js index 3dc770d..7fe7d60 100644 --- a/packages/operators/src/request/concurrentRequest.js +++ b/packages/operators/src/request/concurrentRequest.js @@ -1,4 +1,4 @@ -import { mergeMap, of, tap } from 'rxjs'; +import { mergeMap, of } from 'rxjs'; import { request } from './request'; diff --git a/packages/operators/src/request/concurrentRequest.sample.js b/packages/operators/src/request/concurrentRequest.sample.js deleted file mode 100644 index 696d315..0000000 --- a/packages/operators/src/request/concurrentRequest.sample.js +++ /dev/null @@ -1,31 +0,0 @@ -import { concatAll, map, of } from 'rxjs'; - -import { log } from '../log'; -import { concurrentRequest } from './concurrentRequest'; -import { resolveJSON } from './response'; - -await new Promise(done => { - of( - new URL('https://dummyjson.com/products?limit=10&skip=0&select=title,price'), - new URL('https://dummyjson.com/products?limit=10&skip=10&select=title,price'), - new URL('https://dummyjson.com/products?limit=10&skip=20&select=title,price'), - new URL('https://dummyjson.com/products?limit=10&skip=30&select=title,price'), - new URL('https://dummyjson.com/products?limit=10&skip=40&select=title,price'), - new URL('https://dummyjson.com/products?limit=10&skip=50&select=title,price'), - new URL('https://dummyjson.com/products?limit=10&skip=60&select=title,price'), - new URL('https://dummyjson.com/products?limit=10&skip=70&select=title,price'), - new URL('https://dummyjson.com/products?limit=10&skip=80&select=title,price') - ) - .pipe( - concurrentRequest(4), - log(false), - resolveJSON(), - log(false), - map(({ products }) => products), - concatAll() - ) - .subscribe({ - next: e => console.log(e), - complete: () => done() - }); -}); diff --git a/packages/operators/src/request/concurrentRequest.test.js b/packages/operators/src/request/concurrentRequest.test.js index 208381a..803ed63 100644 --- a/packages/operators/src/request/concurrentRequest.test.js +++ b/packages/operators/src/request/concurrentRequest.test.js @@ -1,54 +1,96 @@ -import { concatMap, delay, of, tap, toArray } from 'rxjs'; +import { concatAll, concatMap, delay, from, map, of, tap, toArray } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; -import { concurrentRequest } from './concurrentRequest'; +import { log } from '../log'; +import { resolveJSON } from './response'; -describe('multi fetch33', function () { +describe('concurrent request - mocked', function () { const testScheduler = new TestScheduler((actual, expected) => { expect(actual).to.eql(expected); }); + const getTriggerValues = () => ({ + a: { t: 2, v: 'a' }, + b: { t: 5, v: 'b' }, + c: { t: 1, v: 'c' }, + d: { t: 3, v: 'd' }, + e: { t: 4, v: 'e' } + }); + beforeEach(function () { - vi.mock('./request.js', async importOriginal => { - return { - request: () => source => source.pipe(concatMap(({ v, t }) => of(v).pipe(delay(t)))) - }; - }); + vi.doMock('./request', importOriginal => ({ + request: () => source => source.pipe(concatMap(({ v, t }) => of(v).pipe(delay(t)))) + })); }); afterEach(() => { - vi.resetAllMocks(); + vi.doUnmock('./request'); }); - test('test', async () => { - const values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(v => ({ v, t: Math.random() * 1000 })); - const sortedResult = [...values].sort((a, b) => a.t - b.t).map(({ v }) => v); + test('classic testing', async () => { + const { concurrentRequest } = await import('./concurrentRequest'); - await new Promise(done => { - of(...values) - .pipe(concurrentRequest(values.length), toArray()) - .subscribe({ next: e => expect(e).to.eql(sortedResult), complete: () => done() }); + const triggerVal = Object.values(getTriggerValues()); + const sortedVal = [...triggerVal].sort((a, b) => a.t - b.t).map(({ v }) => v); + + await new Promise((done, error) => { + from(triggerVal) + .pipe(concurrentRequest(triggerVal.length), toArray()) + .subscribe({ + next: e => expect(e).toStrictEqual(sortedVal), + complete: () => done(), + error: e => error(e) + }); }); }); - test('test2', async () => { - const triggerValues = { - a: { t: 2, v: 'a' }, - b: { t: 5, v: 'b' }, - c: { t: 0, v: 'c' }, - d: { t: 1, v: 'd' }, - e: { t: 1, v: 'd' } - }; - const expectedValues = Object.fromEntries( - Array.from(Object.entries(triggerValues)).map(([k, { v }]) => [k, v]) - ); + test('marble testing', async () => { + const { concurrentRequest } = await import('./concurrentRequest'); + + const triggerVal = getTriggerValues(); + const expectedVal = Object.fromEntries(Object.entries(triggerVal).map(([k, { v }]) => [k, v])); testScheduler.run(({ cold, expectObservable }) => { - expectObservable(cold('-a-b-c-(de)', triggerValues).pipe(concurrentRequest(3), tap())).toBe( - '---a-c--(bde)', - expectedValues - ); + expectObservable( + cold('-a-b-(cd)-e----', triggerVal).pipe(concurrentRequest(Object.keys(triggerVal).length)) + ).toBe('---a--c-(bd)--e', expectedVal); + }); + }); +}); + +describe.skip('concurrent request - demo', function () { + beforeAll(function () { + vi.resetModules(); + }); + + test('sample testing', async () => { + const { concurrentRequest } = await import('./concurrentRequest'); + + await new Promise(done => { + of( + new URL('https://dummyjson.com/products?limit=10&skip=0&select=title,price'), + new URL('https://dummyjson.com/products?limit=10&skip=10&select=title,price'), + new URL('https://dummyjson.com/products?limit=10&skip=20&select=title,price'), + new URL('https://dummyjson.com/products?limit=10&skip=30&select=title,price'), + new URL('https://dummyjson.com/products?limit=10&skip=40&select=title,price'), + new URL('https://dummyjson.com/products?limit=10&skip=50&select=title,price'), + new URL('https://dummyjson.com/products?limit=10&skip=60&select=title,price'), + new URL('https://dummyjson.com/products?limit=10&skip=70&select=title,price'), + new URL('https://dummyjson.com/products?limit=10&skip=80&select=title,price') + ) + .pipe( + concurrentRequest(4), + log(false), + resolveJSON(), + log(false), + map(({ products }) => products), + concatAll() + ) + .subscribe({ + next: e => console.log(e), + complete: () => done() + }); }); }); }); diff --git a/packages/operators/src/request/lazyPagination.js b/packages/operators/src/request/lazyPagination.js index 3987ebc..cc21be2 100644 --- a/packages/operators/src/request/lazyPagination.js +++ b/packages/operators/src/request/lazyPagination.js @@ -1,11 +1,11 @@ -import { concatMap, map } from 'rxjs'; +import { concatMap, map, tap } from 'rxjs'; import { concurrentRequest } from './concurrentRequest'; -export const lazyPagination = ({ resolveRoute }) => { +export const lazyPagination = ({ pager, concurrent, resolveRoute }) => { return source => source.pipe( - concatMap(({ url, pager, concurrent }) => { + concatMap(({ url }) => { return pager.pipe( map(options => resolveRoute(url, options)), concurrentRequest(concurrent) diff --git a/packages/operators/src/request/lazyPagination.test.js b/packages/operators/src/request/lazyPagination.test.js index 0132230..0980813 100644 --- a/packages/operators/src/request/lazyPagination.test.js +++ b/packages/operators/src/request/lazyPagination.test.js @@ -1,22 +1,84 @@ -import { concatAll, map, of, Subject } from 'rxjs'; -import { beforeEach, describe, expect, test } from 'vitest'; +import { concatAll, concatMap, delay, map, of, Subject, tap } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; +import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import { log } from '../log'; -import { lazyPagination } from './lazyPagination'; -import { resolveJSON } from './response'; -describe('lazy pagination operator', function () { +describe('lazy pagination - mocked', function () { + const testScheduler = new TestScheduler((actual, expected) => { + expect(actual).to.eql(expected); + }); + beforeEach(function () { + vi.doMock('./request', importOriginal => ({ + request: () => source => source.pipe(concatMap(({ v, t }) => of(v).pipe(delay(t)))) + })); + }); + + afterEach(() => { + vi.doUnmock('./request'); + }); + + test('classic testing', () => { // }); - test('successfull lazy pagination', async function () { + test('marble testing', async () => { + const { lazyPagination } = await import('./lazyPagination'); + + const pager = new Subject(); + + const triggerValues = { + a: () => pager.next({ value: 'a' }), + b: () => pager.next({ value: 'b' }), + c: () => pager.next({ value: 'c' }), + d: () => pager.next({ value: 'd' }), + e: () => pager.next({ value: 'e' }) + }; + + const responseValues = { + a: { t: 2, v: { value: 'a' } }, + b: { t: 5, v: { value: 'b' } }, + c: { t: 3, v: { value: 'c' } }, + d: { t: 1, v: { value: 'd' } }, + e: { t: 4, v: { value: 'e' } } + }; + + const expectedValues = Object.fromEntries( + Object.entries(responseValues).map(([key, v]) => [key, v.v]) + ); + + testScheduler.run(({ cold, expectObservable }) => { + expectObservable( + of({ url: 'https://example.com' }).pipe( + lazyPagination({ + pager, + concurrent: 5, + resolveRoute: (url, { value }) => responseValues[String(value)] + }) + ) + ).toBe('--daceb--------', expectedValues); + expectObservable(cold('-(abcde)--------', triggerValues).pipe(tap(fn => fn()))); + }); + }); +}); + +describe.skip('lazy pagination - demo', function () { + beforeAll(function () { + vi.resetModules(); + }); + + test('sample testing', async function () { + const { lazyPagination } = await import('./lazyPagination'); + const { resolveJSON } = await import('./response'); const pager = new Subject(); return new Promise(done => { - of({ url: new URL('https://dummyjson.com/products'), pager, concurrent: 4 }) + of({ url: new URL('https://dummyjson.com/products') }) .pipe( lazyPagination({ + pager, + concurrent: 4, resolveRoute: (url, { value, limit = 10 }) => { const newUrl = new URL(`${url}`); newUrl.searchParams.set('skip', value * limit); diff --git a/packages/operators/src/request/request.js b/packages/operators/src/request/request.js index ef7357f..123430b 100644 --- a/packages/operators/src/request/request.js +++ b/packages/operators/src/request/request.js @@ -1,13 +1,18 @@ -import { concatMap } from 'rxjs'; +import { concatMap, throwError } from 'rxjs'; -import { cache } from './cache'; import { resolveBlob, resolveJSON, resolveText } from './response'; import { networkRetry } from './retry'; export const request = () => { return source => source.pipe( - concatMap(req => fetch(req)), + concatMap(req => { + try { + return fetch(req); + } catch { + return throwError(() => new Error('Failed to fetch: resource not valid')); + } + }), networkRetry() ); }; diff --git a/packages/operators/src/request/response.js b/packages/operators/src/request/response.js index fac72f0..eef7390 100644 --- a/packages/operators/src/request/response.js +++ b/packages/operators/src/request/response.js @@ -1,5 +1,5 @@ import { shallowEqual } from 'fast-equals'; -import { concatMap, distinctUntilChanged, map } from 'rxjs'; +import { combineLatest, concatMap, distinctUntilChanged, from, map, of } from 'rxjs'; export const resolve = (type = 'json') => { return source => source.pipe(concatMap(e => e[String(type)]())); @@ -20,7 +20,7 @@ export const resolveBlob = () => { export const distinctUntilResponseChanged = () => { return source => source.pipe( - concatMap(async resp => [resp, await resp.clone().arrayBuffer()]), + concatMap(resp => combineLatest([of(resp), from(resp.clone().arrayBuffer())])), distinctUntilChanged(([, a], [, b]) => shallowEqual(new Uint8Array(a), new Uint8Array(b))), map(([resp]) => resp.clone()) ); diff --git a/packages/operators/src/request/response.test.js b/packages/operators/src/request/response.test.js index c4c2b66..54e1176 100644 --- a/packages/operators/src/request/response.test.js +++ b/packages/operators/src/request/response.test.js @@ -1,16 +1,21 @@ -import { of } from 'rxjs'; -import { afterEach, test, describe, beforeEach, expect } from 'vitest'; +import { map, of, tap } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; +import { afterEach, test, describe, beforeEach, expect, vi } from 'vitest'; import { log } from '../log'; import { distinctUntilResponseChanged, resolveJSON, resolveText } from './response'; describe('response', function () { + const testScheduler = new TestScheduler((actual, expected) => { + expect(actual).to.eql(expected); + }); + beforeEach(function () { // }); afterEach(function () { - // + vi.restoreAllMocks(); }); test('resolve json', () => { @@ -57,4 +62,50 @@ describe('response', function () { }); }); }); + + test('marble testing', async () => { + const triggerValues = { + a: createResponse('a', 'a'), + b: createResponse('b', 'a'), + c: createResponse('c', 'b'), + d: createResponse('d', 'b'), + e: createResponse('e', 'c'), + f: createResponse('f', 'a'), + g: createResponse('g', 'a'), + h: createResponse('h', 'b') + }; + + const expectedValues = Object.fromEntries( + await Promise.all( + Object.entries(triggerValues).map(async ([key, resp]) => { + return [`/${key}`, await resp.clone().arrayBuffer()]; + }) + ) + ); + + vi.spyOn(Response.prototype, 'arrayBuffer').mockImplementation(function (e) { + return [expectedValues[this.url]]; + }); + + testScheduler.run(({ cold, expectObservable }) => { + expectObservable( + cold('-a-b-c-d-e-f-g-h-', triggerValues).pipe( + distinctUntilResponseChanged(), + map(resp => resp.arrayBuffer()) + ) + ).toBe('-a---c---e-f---h-', { + a: triggerValues.a.arrayBuffer(), + c: triggerValues.c.arrayBuffer(), + e: triggerValues.e.arrayBuffer(), + f: triggerValues.f.arrayBuffer(), + h: triggerValues.h.arrayBuffer() + }); + }); + }); }); + +const createResponse = (key, value) => { + const resp = new Response(value); + Object.defineProperty(resp, 'url', { value: `/${key}` }); + return resp; +}; diff --git a/packages/operators/src/request/retry.js b/packages/operators/src/request/retry.js index 18d551a..e176618 100644 --- a/packages/operators/src/request/retry.js +++ b/packages/operators/src/request/retry.js @@ -24,8 +24,7 @@ export const networkRetry = ({ timeout = defaultTimeout, count } = {}) => { retry({ count, delay: () => determineDelayWhenOnline(timeout, ++counter) - }), - catchError(e => console.error(e)) + }) ); }; }; @@ -38,7 +37,7 @@ const determineDelayWhenOnline = (timeout, counter) => { tap(valid => (counter = counter * valid)), // continue only if all observables are valid filter(valid => valid), - tap(() => console.log(`retry: request - next: ${counter} in ${timeout(counter)}s`)), + tap(() => console.log(`retry: request - next: ${counter} in ${timeout(counter)}ms`)), delay(timeout(counter) || timeout) ); }; diff --git a/packages/operators/src/request/retry.test.js b/packages/operators/src/request/retry.test.js index fee16e3..b5551e1 100644 --- a/packages/operators/src/request/retry.test.js +++ b/packages/operators/src/request/retry.test.js @@ -1,30 +1,40 @@ -import { map, of } from 'rxjs'; +import { map } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; -import { beforeEach, describe, expect, test } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import { networkRetry } from './retry'; describe('request retry', function () { - let testScheduler; + const testScheduler = new TestScheduler((actual, expected) => { + expect(actual).deep.equal(expected); + }); - beforeEach(function () { - testScheduler = new TestScheduler((actual, expected) => { - expect(actual).deep.equal(expected); - }); + beforeEach(() => { + // }); - test('network retry', async function () { - let counter = 0; + afterEach(() => { + // + }); + + test('classic testing', () => { + // + }); - const mockObservable = of(null).pipe( - map(() => ({ ok: ++counter >= 3 })), - networkRetry({ timeout: () => 1000 }) - ); + test('marble testing', () => { + const error = new Response('', { status: 500 }); + const success = new Response('a', { status: 200 }); + const orderedResponses = [error, error, success]; - testScheduler.run(({ expectObservable }) => { - expectObservable(mockObservable).toBe('2000ms (a|)', { - a: { ok: true } - }); + testScheduler.run(({ cold, expectObservable }) => { + expectObservable( + cold('-a------', { + a: () => orderedResponses.shift() + }).pipe( + map(fn => fn()), + networkRetry({ timeout: () => 5 }) + ) + ).toBe('-------------a', { a: success }, new Error()); }); }); });