From 92a7f07de7b2c89b286c7169f54db2b805b4f53e Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Tue, 28 Aug 2018 09:35:28 +0200 Subject: [PATCH] Backup support for UI settings. --- .../Apps/AppUISettingsGrain.cs | 111 ++++++++++++++ .../Apps/BackupApps.cs | 29 +++- .../Apps/IAppUISettingsGrain.cs | 25 +++ .../CreateIdentityCommandMiddleware.cs | 2 - .../Areas/Api/Controllers/UI/UIController.cs | 67 ++++++-- .../geolocation-editor.component.ts | 6 +- src/Squidex/app/shared/internal.ts | 1 + src/Squidex/app/shared/module.ts | 2 + .../app/shared/services/ui.service.spec.ts | 42 +++-- src/Squidex/app/shared/services/ui.service.ts | 39 ++--- src/Squidex/app/shared/state/ui.state.spec.ts | 102 +++++++++++++ src/Squidex/app/shared/state/ui.state.ts | 144 ++++++++++++++++++ .../Apps/AppUISettingsGrainTests.cs | 125 +++++++++++++++ .../Contents/GraphQL/GraphQLTestBase.cs | 1 - 14 files changed, 646 insertions(+), 50 deletions(-) create mode 100644 src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs create mode 100644 src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs create mode 100644 src/Squidex/app/shared/state/ui.state.spec.ts create mode 100644 src/Squidex/app/shared/state/ui.state.ts create mode 100644 tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs new file mode 100644 index 000000000..3c88584cd --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs @@ -0,0 +1,111 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Orleans; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public sealed class AppUISettingsGrain : GrainOfGuid, IAppUISettingsGrain + { + private readonly IStore store; + private IPersistence persistence; + private JObject state = new JObject(); + + public AppUISettingsGrain(IStore store) + { + Guard.NotNull(store, nameof(store)); + + this.store = store; + } + + public override Task OnActivateAsync(Guid key) + { + persistence = store.WithSnapshots(GetType(), key, x => state = x); + + return persistence.ReadAsync(); + } + + public Task> GetAsync() + { + return Task.FromResult(state.AsJ()); + } + + public Task SetAsync(J setting) + { + state = setting; + + return persistence.WriteSnapshotAsync(state); + } + + public Task SetAsync(string path, J value) + { + var container = GetContainer(path, out var key); + + if (container == null) + { + throw new InvalidOperationException("Path does not lead to an object."); + } + + container[key] = value; + + return persistence.WriteSnapshotAsync(state); + } + + public Task RemoveAsync(string path) + { + var container = GetContainer(path, out var key); + + if (container != null) + { + container.Remove(key); + } + + return persistence.WriteSnapshotAsync(state); + } + + private JObject GetContainer(string path, out string key) + { + Guard.NotNullOrEmpty(path, nameof(path)); + + var segments = path.Split('.'); + + key = segments[segments.Length - 1]; + + var current = state; + + if (segments.Length > 1) + { + foreach (var segment in segments.Take(segments.Length - 1)) + { + if (!current.TryGetValue(segment, out var temp)) + { + temp = new JObject(); + + current[segment] = temp; + } + + if (temp is JObject next) + { + current = next; + } + else + { + return null; + } + } + } + + return current; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs b/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs index cd5339431..d285ed517 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs @@ -26,6 +26,7 @@ namespace Squidex.Domain.Apps.Entities.Apps public sealed class BackupApps : BackupHandlerWithStore { private const string UsersFile = "Users.json"; + private const string SettingsFile = "Settings.json"; private readonly IGrainFactory grainFactory; private readonly IUserResolver userResolver; private readonly IAppsByNameIndex appsByNameIndex; @@ -69,9 +70,10 @@ namespace Squidex.Domain.Apps.Entities.Apps } } - public override Task BackupAsync(Guid appId, BackupWriter writer) + public override async Task BackupAsync(Guid appId, BackupWriter writer) { - return WriterUsersAsync(writer); + await WriteUsersAsync(writer); + await WriteSettingsAsync(writer, appId); } public async override Task RestoreEventAsync(Envelope @event, Guid appId, BackupReader reader, RefToken actor) @@ -120,6 +122,11 @@ namespace Squidex.Domain.Apps.Entities.Apps } } + public override Task RestoreAsync(Guid appId, BackupReader reader) + { + return ReadSettingsAsync(reader, appId); + } + private async Task ReserveAppAsync(Guid appId) { if (!(isReserved = await appsByNameIndex.ReserveAppAsync(appId, appName))) @@ -167,11 +174,25 @@ namespace Squidex.Domain.Apps.Entities.Apps usersWithEmail = json.ToObject>(); } - private Task WriterUsersAsync(BackupWriter writer) + private async Task WriteUsersAsync(BackupWriter writer) { var json = JObject.FromObject(usersWithEmail); - return writer.WriteJsonAsync(UsersFile, json); + await writer.WriteJsonAsync(UsersFile, json); + } + + private async Task WriteSettingsAsync(BackupWriter writer, Guid appId) + { + var json = await grainFactory.GetGrain(appId).GetAsync(); + + await writer.WriteJsonAsync(SettingsFile, json); + } + + private async Task ReadSettingsAsync(BackupReader reader, Guid appId) + { + var json = await reader.ReadJsonAttachmentAsync(SettingsFile); + + await grainFactory.GetGrain(appId).SetAsync((JObject)json); } public override async Task CompleteRestoreAsync(Guid appId, BackupReader reader) diff --git a/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs new file mode 100644 index 000000000..23b680e51 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Orleans; +using Squidex.Infrastructure.Orleans; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public interface IAppUISettingsGrain : IGrainWithGuidKey + { + Task> GetAsync(); + + Task SetAsync(string path, J value); + + Task SetAsync(J setting); + + Task RemoveAsync(string path); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateIdentityCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateIdentityCommandMiddleware.cs index bcdfc7f09..24d07ac4d 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateIdentityCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateIdentityCommandMiddleware.cs @@ -6,9 +6,7 @@ // ========================================================================== using System; -using System.Collections.Generic; using System.Threading.Tasks; -using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Templates.Builders; using Squidex.Domain.Apps.Entities.Schemas.Commands; diff --git a/src/Squidex/Areas/Api/Controllers/UI/UIController.cs b/src/Squidex/Areas/Api/Controllers/UI/UIController.cs index 2c87e8624..80c4ea6c6 100644 --- a/src/Squidex/Areas/Api/Controllers/UI/UIController.cs +++ b/src/Squidex/Areas/Api/Controllers/UI/UIController.cs @@ -5,11 +5,15 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; +using Newtonsoft.Json.Linq; using NSwag.Annotations; +using Orleans; using Squidex.Areas.Api.Controllers.UI.Models; using Squidex.Config; +using Squidex.Domain.Apps.Entities.Apps; using Squidex.Infrastructure.Commands; using Squidex.Pipeline; @@ -23,29 +27,74 @@ namespace Squidex.Areas.Api.Controllers.UI public sealed class UIController : ApiController { private readonly MyUIOptions uiOptions; + private readonly IGrainFactory grainFactory; - public UIController(ICommandBus commandBus, IOptions uiOptions) + public UIController(ICommandBus commandBus, IOptions uiOptions, IGrainFactory grainFactory) : base(commandBus) { this.uiOptions = uiOptions.Value; + this.grainFactory = grainFactory; } /// /// Get ui settings. /// + /// The name of the app. + /// + /// 200 => UI settings returned. + /// 404 => App not found. + /// [HttpGet] - [Route("ui/settings/")] + [Route("apps/{app}/ui/settings/")] [ProducesResponseType(typeof(UISettingsDto), 200)] [ApiCosts(0)] - public IActionResult GetSettings() + public async Task GetSettings(string app) { - var dto = new UISettingsDto - { - MapType = uiOptions.Map?.Type ?? "OSM", - MapKey = uiOptions.Map?.GoogleMaps?.Key - }; + var result = await grainFactory.GetGrain(App.Id).GetAsync(); - return Ok(dto); + result.Value["mapType"] = uiOptions.Map?.Type ?? "OSM"; + result.Value["mapKey"] = uiOptions.Map?.GoogleMaps?.Key; + + return Ok(result.Value); + } + + /// + /// Set ui settings. + /// + /// The name of the app. + /// The name of the setting. + /// The name of the value. + /// + /// 200 => UI setting set. + /// 404 => App not found. + /// + [HttpPut] + [Route("apps/{app}/ui/settings/{key}")] + [ApiCosts(0)] + public async Task PutSetting(string app, string key, [FromBody] JToken value) + { + await grainFactory.GetGrain(App.Id).SetAsync(key, value); + + return NoContent(); + } + + /// + /// Remove ui settings. + /// + /// The name of the app. + /// The name of the setting. + /// + /// 200 => UI setting removed. + /// 404 => App not found. + /// + [HttpDelete] + [Route("apps/{app}/ui/settings/{key}")] + [ApiCosts(0)] + public async Task DeleteSetting(string app, string key) + { + await grainFactory.GetGrain(App.Id).RemoveAsync(key); + + return NoContent(); } } } diff --git a/src/Squidex/app/shared/components/geolocation-editor.component.ts b/src/Squidex/app/shared/components/geolocation-editor.component.ts index d64a39950..7239840db 100644 --- a/src/Squidex/app/shared/components/geolocation-editor.component.ts +++ b/src/Squidex/app/shared/components/geolocation-editor.component.ts @@ -11,7 +11,7 @@ import { ControlValueAccessor, FormBuilder, NG_VALUE_ACCESSOR } from '@angular/f import { ResourceLoaderService, Types, - UIService, + UIState, ValidatorsEx } from '@app/shared/internal'; @@ -72,7 +72,7 @@ export class GeolocationEditorComponent implements ControlValueAccessor, AfterVi constructor( private readonly resourceLoader: ResourceLoaderService, private readonly formBuilder: FormBuilder, - private readonly uiService: UIService + private readonly uiState: UIState ) { } @@ -158,7 +158,7 @@ export class GeolocationEditorComponent implements ControlValueAccessor, AfterVi } public ngAfterViewInit() { - this.uiService.getSettings() + this.uiState.settings .subscribe(settings => { this.isGoogleMaps = settings.mapType === 'GoogleMaps'; diff --git a/src/Squidex/app/shared/internal.ts b/src/Squidex/app/shared/internal.ts index 51fb48eb8..669db06e0 100644 --- a/src/Squidex/app/shared/internal.ts +++ b/src/Squidex/app/shared/internal.ts @@ -61,6 +61,7 @@ export * from './state/rule-events.state'; export * from './state/rules.state'; export * from './state/schemas.forms'; export * from './state/schemas.state'; +export * from './state/ui.state'; export * from './utils/messages'; diff --git a/src/Squidex/app/shared/module.ts b/src/Squidex/app/shared/module.ts index a32ac6428..d156853f6 100644 --- a/src/Squidex/app/shared/module.ts +++ b/src/Squidex/app/shared/module.ts @@ -67,6 +67,7 @@ import { SchemasService, SchemasState, UIService, + UIState, UnsetAppGuard, UnsetContentGuard, UsagesService, @@ -179,6 +180,7 @@ export class SqxSharedModule { SchemasService, SchemasState, UIService, + UIState, UnsetAppGuard, UnsetContentGuard, UsagesService, diff --git a/src/Squidex/app/shared/services/ui.service.spec.ts b/src/Squidex/app/shared/services/ui.service.spec.ts index 2e27ece4d..1d4c280b4 100644 --- a/src/Squidex/app/shared/services/ui.service.spec.ts +++ b/src/Squidex/app/shared/services/ui.service.spec.ts @@ -34,28 +34,22 @@ describe('UIService', () => { it('should make get request to get settings', inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { - let settings1: UISettingsDto; - let settings2: UISettingsDto; + let settings: UISettingsDto; - uiService.getSettings().subscribe(result => { - settings1 = result; + uiService.getSettings('my-app').subscribe(result => { + settings = result; }); const response: UISettingsDto = { mapType: 'OSM', mapKey: '' }; - const req = httpMock.expectOne('http://service/p/api/ui/settings'); + const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings'); expect(req.request.method).toEqual('GET'); expect(req.request.headers.get('If-Match')).toBeNull(); req.flush(response); - uiService.getSettings().subscribe(result => { - settings2 = result; - }); - - expect(settings1!).toEqual(response); - expect(settings2!).toEqual(response); + expect(settings!).toEqual(response); })); it('should return default settings when error occurs', @@ -63,11 +57,11 @@ describe('UIService', () => { let settings: UISettingsDto; - uiService.getSettings().subscribe(result => { + uiService.getSettings('my-app').subscribe(result => { settings = result; }); - const req = httpMock.expectOne('http://service/p/api/ui/settings'); + const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings'); expect(req.request.method).toEqual('GET'); expect(req.request.headers.get('If-Match')).toBeNull(); @@ -76,4 +70,26 @@ describe('UIService', () => { expect(settings!).toBeDefined(); })); + + it('should make put request to set value', + inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { + + uiService.putSetting('my-app', 'root.nested', 123).subscribe(); + + const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings/root.nested'); + + expect(req.request.method).toEqual('PUT'); + expect(req.request.headers.get('If-Match')).toBeNull(); + })); + + it('should make delete request to remove value', + inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { + + uiService.deleteSetting('my-app', 'root.nested').subscribe(); + + const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings/root.nested'); + + expect(req.request.method).toEqual('DELETE'); + expect(req.request.headers.get('If-Match')).toBeNull(); + })); }); \ No newline at end of file diff --git a/src/Squidex/app/shared/services/ui.service.ts b/src/Squidex/app/shared/services/ui.service.ts index 605b73747..dc590d332 100644 --- a/src/Squidex/app/shared/services/ui.service.ts +++ b/src/Squidex/app/shared/services/ui.service.ts @@ -8,38 +8,41 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable, of } from 'rxjs'; -import { catchError, tap } from 'rxjs/operators'; +import { catchError } from 'rxjs/operators'; import { ApiUrlConfig } from '@app/framework'; export interface UISettingsDto { mapType: string; - mapKey: string; + mapKey?: string; } @Injectable() export class UIService { - private settings: UISettingsDto; - constructor( private readonly http: HttpClient, private readonly apiUrl: ApiUrlConfig ) { } - public getSettings(): Observable { - if (this.settings) { - return of(this.settings); - } else { - const url = this.apiUrl.buildUrl(`api/ui/settings`); - - return this.http.get(url).pipe( - catchError(error => { - return of({ regexSuggestions: [], mapType: 'OSM', mapKey: '' }); - }), - tap(settings => { - this.settings = settings; - })); - } + public getSettings(appName: string): Observable { + const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings`); + + return this.http.get(url).pipe( + catchError(_ => { + return of({ regexSuggestions: [], mapType: 'OSM', mapKey: '' }); + })); + } + + public putSetting(appName: string, key: string, value: any): Observable { + const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings/${key}`); + + return this.http.put(url, value); + } + + public deleteSetting(appName: string, key: string): Observable { + const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings/${key}`); + + return this.http.delete(url); } } \ No newline at end of file diff --git a/src/Squidex/app/shared/state/ui.state.spec.ts b/src/Squidex/app/shared/state/ui.state.spec.ts new file mode 100644 index 000000000..1da9cc5d1 --- /dev/null +++ b/src/Squidex/app/shared/state/ui.state.spec.ts @@ -0,0 +1,102 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { of } from 'rxjs'; +import { IMock, It, Mock, Times } from 'typemoq'; + +import { AppsState } from '@app/shared'; + +import { UIService } from './../services/ui.service'; +import { UIState } from './ui.state'; + +describe('UIState', () => { + const app = 'my-app'; + + const oldSettings = { + mapType: 'OSM' + }; + + let appsState: IMock; + let uiService: IMock; + let uiState: UIState; + + beforeEach(() => { + appsState = Mock.ofType(); + + appsState.setup(x => x.appName) + .returns(() => app); + + uiService = Mock.ofType(); + + uiService.setup(x => x.getSettings(app)) + .returns(() => of(oldSettings)); + + uiService.setup(x => x.putSetting(app, It.isAnyString(), It.isAny())) + .returns(() => of({})); + + uiService.setup(x => x.deleteSetting(app, It.isAnyString())) + .returns(() => of({})); + + uiState = new UIState(appsState.object, uiService.object); + }); + + it('should load settings', () => { + expect(uiState.snapshot.settings).toEqual(oldSettings); + }); + + it('should add value to snapshot when set', () => { + uiState.set('root.nested', 123); + + expect(uiState.snapshot.settings).toEqual({ + mapType: 'OSM', + root: { + nested: 123 + } + }); + + uiState.get('root', {}).subscribe(x => { + expect(x).toEqual({ nested: 123 }); + }); + + uiState.get('root.nested', 0).subscribe(x => { + expect(x).toEqual(123); + }); + + uiState.get('root.notfound', 1337).subscribe(x => { + expect(x).toEqual(1337); + }); + + uiService.verify(x => x.putSetting(app, 'root.nested', 123), Times.once()); + }); + + it('should remove value from snapshot when removed', () => { + uiState.set('root.nested1', 123); + uiState.set('root.nested2', 123); + uiState.remove('root.nested1'); + + expect(uiState.snapshot.settings).toEqual({ + mapType: 'OSM', + root: { + nested2: 123 + } + }); + + uiState.get('root', {}).subscribe(x => { + expect(x).toEqual({ nested2: 123 }); + }); + + uiState.get('root.nested2', 0).subscribe(x => { + expect(x).toEqual(123); + }); + + uiState.get('root.nested1', 1337).subscribe(x => { + expect(x).toEqual(1337); + }); + + uiService.verify(x => x.deleteSetting(app, 'root.nested1'), Times.once()); + }); +}); \ No newline at end of file diff --git a/src/Squidex/app/shared/state/ui.state.ts b/src/Squidex/app/shared/state/ui.state.ts new file mode 100644 index 000000000..c7edafdf4 --- /dev/null +++ b/src/Squidex/app/shared/state/ui.state.ts @@ -0,0 +1,144 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { distinctUntilChanged, map, tap } from 'rxjs/operators'; + +import { State, Types } from '@app/framework'; + +import { AppsState } from './apps.state'; + +import { UIService, UISettingsDto } from './../services/ui.service'; + +interface Snapshot { + settings: object & any; +} + +@Injectable() +export class UIState extends State { + public settings = + this.changes.pipe(map(x => x.settings), + distinctUntilChanged()); + + public get(path: string, defaultValue: T) { + return this.settings.pipe(map(x => this.getValue(x, path, defaultValue)), + distinctUntilChanged()); + } + + constructor( + private readonly appsState: AppsState, + private readonly uiService: UIService + ) { + super({ settings: { mapType: 'OSM' } }); + + if (appsState.selectedApp && Types.isFunction(appsState.selectedApp.subscribe)) { + appsState.selectedApp.subscribe(app => { + if (app) { + this.load(true); + } + }); + } else { + this.load(true); + } + } + + public load(reset = false): Observable { + if (!reset) { + this.resetState(); + } + + return this.loadInternal(); + } + + private loadInternal(): Observable { + return this.uiService.getSettings(this.appName).pipe( + tap(dtos => { + return this.next({ settings: dtos }); + })); + } + + public set(path: string, value: any) { + const { key, current, root } = this.getContainer(path); + + if (current && key) { + this.uiService.putSetting(this.appName, path, value).subscribe(); + + current[key] = value; + + this.next({ settings: root }); + } + } + + public remove(path: string) { + const { key, current, root } = this.getContainer(path); + + if (current && key) { + this.uiService.deleteSetting(this.appName, path).subscribe(); + + delete current[key]; + + this.next({ settings: root }); + } + } + + private getContainer(path: string) { + const segments = path.split('.'); + + let current = { ...this.snapshot.settings }; + + const root = current; + + if (segments.length > 0) { + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i]; + + let temp = current[segment]; + + if (!temp) { + temp = {}; + } else { + temp = { ...temp }; + } + + current[segment] = temp; + + if (!Types.isObject(temp)) { + return { key: null, current: null, root: null }; + } + + current = temp; + } + } + + return { key: segments[segments.length - 1], current, root }; + } + + private getValue(setting: object & UISettingsDto, path: string, defaultValue: T) { + const segments = path.split('.'); + + let current = setting; + + for (let segment of segments) { + let temp = current[segment]; + + if (temp) { + current[segment] = temp; + } else { + return defaultValue; + } + + current = temp; + } + + return current; + } + + private get appName() { + return this.appsState.appName; + } +} \ No newline at end of file diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs new file mode 100644 index 000000000..178137bd2 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs @@ -0,0 +1,125 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using FakeItEasy; +using Newtonsoft.Json.Linq; +using Squidex.Infrastructure.Orleans; +using Squidex.Infrastructure.States; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public sealed class AppUISettingsGrainTests + { + private readonly IStore store = A.Fake>(); + private readonly IPersistence persistence = A.Fake>(); + private readonly AppUISettingsGrain sut; + + public AppUISettingsGrainTests() + { + A.CallTo(() => store.WithSnapshots(A.Ignored, A.Ignored, A>.Ignored)) + .Returns(persistence); + + sut = new AppUISettingsGrain(store); + sut.OnActivateAsync(Guid.Empty).Wait(); + } + + [Fact] + public async Task Should_set_setting() + { + await sut.SetAsync(new JObject(new JProperty("key", 15)).AsJ()); + + var actual = await sut.GetAsync(); + + var expected = + new JObject( + new JProperty("key", 15)); + + Assert.Equal(expected.ToString(), actual.Value.ToString()); + } + + [Fact] + public async Task Should_set_root_value() + { + await sut.SetAsync("key", ((JToken)123).AsJ()); + + var actual = await sut.GetAsync(); + + var expected = + new JObject( + new JProperty("key", 123)); + + Assert.Equal(expected.ToString(), actual.Value.ToString()); + } + + [Fact] + public async Task Should_remove_root_value() + { + await sut.SetAsync("key", ((JToken)123).AsJ()); + await sut.RemoveAsync("key"); + + var actual = await sut.GetAsync(); + + var expected = new JObject(); + + Assert.Equal(expected.ToString(), actual.Value.ToString()); + } + + [Fact] + public async Task Should_set_nested_value() + { + await sut.SetAsync("root.nested", ((JToken)123).AsJ()); + + var actual = await sut.GetAsync(); + + var expected = + new JObject( + new JProperty("root", + new JObject( + new JProperty("nested", 123)))); + + Assert.Equal(expected.ToString(), actual.Value.ToString()); + } + + [Fact] + public async Task Should_remove_nested_value() + { + await sut.SetAsync("root.nested", ((JToken)123).AsJ()); + await sut.RemoveAsync("root.nested"); + + var actual = await sut.GetAsync(); + + var expected = + new JObject( + new JProperty("root", new JObject())); + + Assert.Equal(expected.ToString(), actual.Value.ToString()); + } + + [Fact] + public async Task Should_throw_exception_if_nested_not_an_object() + { + await sut.SetAsync("root.nested", ((JToken)123).AsJ()); + + await Assert.ThrowsAsync(() => sut.SetAsync("root.nested.value", ((JToken)123).AsJ())); + } + + [Fact] + public Task Should_do_nothing_if_deleting_and_nested_not_found() + { + return sut.RemoveAsync("root.nested"); + } + + [Fact] + public Task Should_do_nothing_if_deleting_and_key_not_found() + { + return sut.RemoveAsync("root"); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs index 7d95261dc..0d152bbbc 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs @@ -13,7 +13,6 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using Newtonsoft.Json.Serialization; using NodaTime.Extensions; using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Apps;