mirror of https://github.com/Squidex/squidex.git
Browse Source
* Batch references. * Tests for resolvers. * Easier async code in tests * Tests fixes.pull/806/head
committed by
GitHub
41 changed files with 638 additions and 432 deletions
@ -0,0 +1,209 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { UIOptions } from '@app/framework'; |
|||
import { firstValueFrom, of, throwError } from 'rxjs'; |
|||
import { IMock, Mock, Times } from 'typemoq'; |
|||
import { ContentsDto, ContentsService } from '../services/contents.service'; |
|||
import { createContent } from '../services/contents.service.spec'; |
|||
import { ResolveContents } from './resolvers'; |
|||
import { TestValues } from './_test-helpers'; |
|||
|
|||
describe('ResolveContents', () => { |
|||
const { |
|||
app, |
|||
appsState, |
|||
} = TestValues; |
|||
|
|||
const uiOptions = new UIOptions({ |
|||
referencesDropdownItemCount: 100, |
|||
}); |
|||
|
|||
let contentsService: IMock<ContentsService>; |
|||
let contentsResolver: ResolveContents; |
|||
|
|||
const contents = [ |
|||
createContent(1), |
|||
createContent(2), |
|||
createContent(3), |
|||
createContent(4), |
|||
]; |
|||
|
|||
beforeEach(() => { |
|||
contentsService = Mock.ofType<ContentsService>(); |
|||
contentsResolver = new ResolveContents(uiOptions, appsState.object, contentsService.object); |
|||
}); |
|||
|
|||
it('should not resolve contents immediately', () => { |
|||
const ids = ['id1', 'id2']; |
|||
|
|||
contentsService.setup(x => x.getAllContents(app, { ids })) |
|||
.returns(() => of(new ContentsDto([], 2, [contents[0], contents[1]]))); |
|||
|
|||
return expectAsync(firstValueFrom(contentsResolver.resolveMany(ids))).toBePending(); |
|||
}); |
|||
|
|||
it('should resolve content from one request after delay', async () => { |
|||
const ids = ['id1', 'id2']; |
|||
|
|||
contentsService.setup(x => x.getAllContents(app, { ids })) |
|||
.returns(() => of(new ContentsDto([], 2, [contents[0], contents[1]]))); |
|||
|
|||
const result = await firstValueFrom(contentsResolver.resolveMany(ids)); |
|||
|
|||
expect(result.items).toEqual([ |
|||
contents[0], |
|||
contents[1], |
|||
]); |
|||
}); |
|||
|
|||
it('should resolve content if not found', async () => { |
|||
const ids = ['id1', 'id2']; |
|||
|
|||
contentsService.setup(x => x.getAllContents(app, { ids })) |
|||
.returns(() => of(new ContentsDto([], 2, [contents[0]]))); |
|||
|
|||
const result = await firstValueFrom(contentsResolver.resolveMany(ids)); |
|||
|
|||
expect(result.items).toEqual([ |
|||
contents[0], |
|||
]); |
|||
}); |
|||
|
|||
it('should resolve errors', () => { |
|||
const ids = ['id1', 'id2']; |
|||
|
|||
contentsService.setup(x => x.getAllContents(app, { ids })) |
|||
.returns(() => throwError(() => new Error('error'))); |
|||
|
|||
return expectAsync(firstValueFrom(contentsResolver.resolveMany(ids))).toBeRejected(); |
|||
}); |
|||
|
|||
it('should batch results', async () => { |
|||
const ids1 = ['id1', 'id2']; |
|||
const ids2 = ['id2', 'id3']; |
|||
|
|||
const ids = ['id1', 'id2', 'id3']; |
|||
|
|||
contentsService.setup(x => x.getAllContents(app, { ids })) |
|||
.returns(() => of(new ContentsDto([], 2, [contents[0], contents[1], contents[2]]))); |
|||
|
|||
const result1Promise = firstValueFrom(contentsResolver.resolveMany(ids1)); |
|||
const result2Promise = firstValueFrom(contentsResolver.resolveMany(ids2)); |
|||
|
|||
const [result1, result2] = await Promise.all([result1Promise, result2Promise]); |
|||
|
|||
expect(result1.items).toEqual([ |
|||
contents[0], |
|||
contents[1], |
|||
]); |
|||
|
|||
expect(result2.items).toEqual([ |
|||
contents[1], |
|||
contents[2], |
|||
]); |
|||
|
|||
contentsService.verify(x => x.getAllContents(app, { ids }), Times.once()); |
|||
}); |
|||
|
|||
it('should cache results for parallel requests', async () => { |
|||
const ids = ['id1', 'id2']; |
|||
|
|||
contentsService.setup(x => x.getAllContents(app, { ids })) |
|||
.returns(() => of(new ContentsDto([], 2, [contents[0], contents[1]]))); |
|||
|
|||
const result1Promise = firstValueFrom(contentsResolver.resolveMany(ids)); |
|||
const result2Promise = firstValueFrom(contentsResolver.resolveMany(ids)); |
|||
|
|||
const [result1, result2] = await Promise.all([result1Promise, result2Promise]); |
|||
|
|||
expect(result1.items).toEqual([ |
|||
contents[0], |
|||
contents[1], |
|||
]); |
|||
|
|||
expect(result2.items).toEqual([ |
|||
contents[0], |
|||
contents[1], |
|||
]); |
|||
|
|||
contentsService.verify(x => x.getAllContents(app, { ids }), Times.once()); |
|||
}); |
|||
|
|||
it('should cache results', async () => { |
|||
const ids = ['id1', 'id2']; |
|||
|
|||
contentsService.setup(x => x.getAllContents(app, { ids })) |
|||
.returns(() => of(new ContentsDto([], 2, [contents[0], contents[1]]))); |
|||
|
|||
const result1 = await firstValueFrom(contentsResolver.resolveMany(ids)); |
|||
const result2 = await firstValueFrom(contentsResolver.resolveMany(ids)); |
|||
|
|||
expect(result1.items).toEqual([ |
|||
contents[0], |
|||
contents[1], |
|||
]); |
|||
|
|||
expect(result2.items).toEqual([ |
|||
contents[0], |
|||
contents[1], |
|||
]); |
|||
|
|||
contentsService.verify(x => x.getAllContents(app, { ids }), Times.once()); |
|||
}); |
|||
|
|||
it('should resolve all contents', async () => { |
|||
const schema = 'schema1'; |
|||
|
|||
contentsService.setup(x => x.getContents(app, schema, { take: 100 })) |
|||
.returns(() => of(new ContentsDto([], 2, [contents[0]]))); |
|||
|
|||
const result = await firstValueFrom(contentsResolver.resolveAll('schema1')); |
|||
|
|||
expect(result.items).toEqual([ |
|||
contents[0], |
|||
]); |
|||
}); |
|||
|
|||
it('should cache all contents for parallel requests', async () => { |
|||
const schema = 'schema1'; |
|||
|
|||
contentsService.setup(x => x.getContents(app, schema, { take: 100 })) |
|||
.returns(() => of(new ContentsDto([], 2, [contents[0]]))); |
|||
|
|||
const result1Promise = await firstValueFrom(contentsResolver.resolveAll('schema1')); |
|||
const result2Promise = await firstValueFrom(contentsResolver.resolveAll('schema1')); |
|||
|
|||
const [result1, result2] = await Promise.all([result1Promise, result2Promise]); |
|||
|
|||
expect(result1.items).toEqual([ |
|||
contents[0], |
|||
]); |
|||
|
|||
expect(result2.items).toEqual([ |
|||
contents[0], |
|||
]); |
|||
}); |
|||
|
|||
it('should cache all contents', async () => { |
|||
const schema = 'schema1'; |
|||
|
|||
contentsService.setup(x => x.getContents(app, schema, { take: 100 })) |
|||
.returns(() => of(new ContentsDto([], 2, [contents[0]]))); |
|||
|
|||
const result1 = await firstValueFrom(contentsResolver.resolveAll('schema1')); |
|||
const result2 = await firstValueFrom(contentsResolver.resolveAll('schema1')); |
|||
|
|||
expect(result1.items).toEqual([ |
|||
contents[0], |
|||
]); |
|||
|
|||
expect(result2.items).toEqual([ |
|||
contents[0], |
|||
]); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,206 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { Injectable } from '@angular/core'; |
|||
import { Observable, from, of, shareReplay } from 'rxjs'; |
|||
import { UIOptions } from '@app/framework'; |
|||
import { AssetDto, AssetsDto, AssetsService } from './../services/assets.service'; |
|||
import { AppsState } from './apps.state'; |
|||
import { ContentDto, ContentsDto, ContentsService } from './../services/contents.service'; |
|||
|
|||
abstract class ResolverBase<T extends { id: string }, TResult extends { items: ReadonlyArray<T> }> { |
|||
private readonly items: { [id: string]: Deferred<T | undefined> } = {}; |
|||
private pending: { [id: string]: boolean } | null = null; |
|||
|
|||
public resolveMany(ids: ReadonlyArray<string>): Observable<TResult> { |
|||
if (ids.length === 0) { |
|||
return of(this.createResult([])); |
|||
} |
|||
|
|||
const nonResolved: string[] = []; |
|||
|
|||
const promises: Promise<T | undefined>[] = []; |
|||
|
|||
for (const id of ids) { |
|||
let deferred = this.items[id]; |
|||
|
|||
if (!deferred) { |
|||
deferred = new Deferred<T>(); |
|||
this.items[id] = deferred; |
|||
|
|||
nonResolved.push(id); |
|||
} |
|||
|
|||
promises.push(deferred.promise); |
|||
} |
|||
|
|||
if (nonResolved.length > 0) { |
|||
if (this.pending === null) { |
|||
this.pending = {}; |
|||
|
|||
setTimeout(() => { |
|||
this.resolvePending(); |
|||
}, 100); |
|||
} |
|||
|
|||
for (const id of nonResolved) { |
|||
this.pending[id] = true; |
|||
} |
|||
} |
|||
|
|||
return from(this.buildPromise(promises)); |
|||
} |
|||
|
|||
private async buildPromise(promises: Promise<T | undefined>[]) { |
|||
const promise = await Promise.all(promises); |
|||
|
|||
return this.createResult(promise.filter(x => !!x) as any); |
|||
} |
|||
|
|||
private resolvePending() { |
|||
if (!this.pending) { |
|||
return; |
|||
} |
|||
|
|||
const allIds = Object.keys(this.pending); |
|||
|
|||
if (allIds.length === 0) { |
|||
return; |
|||
} |
|||
|
|||
this.pending = null; |
|||
|
|||
for (const ids of chunkArray(allIds, 100)) { |
|||
this.resolveIds(ids); |
|||
} |
|||
} |
|||
|
|||
protected abstract createResult(items: T[]): TResult; |
|||
|
|||
protected abstract loadMany(ids: string[]): Observable<TResult>; |
|||
|
|||
private resolveIds(ids: string[]) { |
|||
this.loadMany(ids) |
|||
.subscribe({ |
|||
next: results => { |
|||
for (const id of ids) { |
|||
const content = results.items.find(x => x.id === id); |
|||
|
|||
this.items[id]?.resolve(content); |
|||
} |
|||
}, |
|||
error: ex => { |
|||
for (const id of ids) { |
|||
this.items[id]?.reject(ex); |
|||
} |
|||
}, |
|||
}); |
|||
} |
|||
} |
|||
|
|||
@Injectable() |
|||
export class ResolveContents extends ResolverBase<ContentDto, ContentsDto> { |
|||
private readonly schemas: { [name: string]: Observable<ContentsDto> } = {}; |
|||
private readonly itemCount; |
|||
|
|||
constructor( |
|||
uiOptions: UIOptions, |
|||
private readonly appsState: AppsState, |
|||
private readonly contentsService: ContentsService, |
|||
) { |
|||
super(); |
|||
|
|||
this.itemCount = uiOptions.get('referencesDropdownItemCount'); |
|||
} |
|||
|
|||
public resolveAll(schema: string) { |
|||
let result = this.schemas[schema]; |
|||
|
|||
if (!result) { |
|||
result = this.contentsService.getContents(this.appName, schema, { take: this.itemCount }).pipe(shareReplay(1)); |
|||
|
|||
this.schemas[schema] = result; |
|||
} |
|||
|
|||
return result; |
|||
} |
|||
|
|||
protected createResult(items: ContentDto[]) { |
|||
return new ContentsDto([], items.length, items); |
|||
} |
|||
|
|||
protected loadMany(ids: string[]) { |
|||
return this.contentsService.getAllContents(this.appName, { ids }); |
|||
} |
|||
|
|||
private get appName() { |
|||
return this.appsState.appName; |
|||
} |
|||
} |
|||
|
|||
@Injectable() |
|||
export class ResolveAssets extends ResolverBase<AssetDto, AssetsDto> { |
|||
constructor( |
|||
private readonly appsState: AppsState, |
|||
private readonly assetsService: AssetsService, |
|||
) { |
|||
super(); |
|||
} |
|||
|
|||
protected createResult(items: AssetDto[]) { |
|||
return new AssetsDto(items.length, items); |
|||
} |
|||
|
|||
protected loadMany(ids: string[]) { |
|||
return this.assetsService.getAssets(this.appName, { ids }); |
|||
} |
|||
|
|||
private get appName() { |
|||
return this.appsState.appName; |
|||
} |
|||
} |
|||
|
|||
function chunkArray<T>(array: T[], size: number): T[][] { |
|||
if (array.length > size) { |
|||
return [array.slice(0, size), ...chunkArray(array.slice(size), size)]; |
|||
} else { |
|||
return [array]; |
|||
} |
|||
} |
|||
|
|||
class Deferred<T> { |
|||
private handleResolve: Function; |
|||
private handleReject: Function; |
|||
private isHandled = false; |
|||
|
|||
public readonly promise: Promise<T>; |
|||
|
|||
constructor() { |
|||
this.promise = new Promise<T>((resolve, reject) => { |
|||
this.handleResolve = resolve; |
|||
this.handleReject = reject; |
|||
}); |
|||
} |
|||
|
|||
public resolve(value: T | PromiseLike<T>) { |
|||
if (this.isHandled) { |
|||
return; |
|||
} |
|||
|
|||
this.isHandled = true; |
|||
this.handleResolve(value); |
|||
} |
|||
|
|||
public reject(reason?: any) { |
|||
if (this.isHandled) { |
|||
return; |
|||
} |
|||
|
|||
this.isHandled = true; |
|||
this.handleReject(reason); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue