From 9c154b7c3ce4b9adef39b94905a34cca959b20a9 Mon Sep 17 00:00:00 2001 From: Simon <33730997+TheSlimvReal@users.noreply.github.com> Date: Tue, 24 May 2022 13:57:18 +0200 Subject: [PATCH] refactor: Reworked Startup Process (#1259) solves #595 Co-authored-by: Sebastian Leidig --- src/app/app.component.spec.ts | 89 ++------ src/app/app.component.ts | 17 +- src/app/app.module.ts | 6 +- src/app/app.routing.ts | 7 +- ...children-count-dashboard.component.spec.ts | 4 +- .../demo-data/demo-note-generator.service.ts | 41 +--- .../notes/demo-data/notes_group-stories.ts | 11 +- .../demo-data/notes_individual-stories.ts | 25 ++- src/app/core/admin/admin/admin.component.ts | 4 +- .../core/analytics/analytics.service.spec.ts | 10 +- src/app/core/app-config/app-config.module.ts | 55 ----- src/app/core/app-config/app-config.ts | 46 ++-- src/app/core/config/config-fix.ts | 1 - src/app/core/config/config.service.spec.ts | 63 +++--- src/app/core/config/config.service.ts | 84 ++++--- .../config/demo-config-generator.service.ts | 22 ++ src/app/core/database/database.module.ts | 15 ++ src/app/core/database/database.ts | 3 - src/app/core/database/pouch-database.spec.ts | 15 +- src/app/core/database/pouch-database.ts | 116 +++++----- .../demo-data-initializer.service.spec.ts | 5 +- .../demo-data-initializer.service.ts | 14 +- .../filter-generator.service.spec.ts | 32 +-- .../edit-entity-array.component.spec.ts | 19 +- .../edit-single-entity.component.spec.ts | 20 +- .../database-indexing.service.spec.ts | 44 +--- .../database-indexing.service.ts | 30 +-- .../core/entity/mock-entity-mapper-service.ts | 5 +- src/app/core/entity/model/entity.spec.ts | 10 +- .../navigation/navigation.component.spec.ts | 5 +- .../ability/ability.service.spec.ts | 208 +++++++++--------- .../permissions/ability/ability.service.ts | 24 +- .../permission-enforcer.service.spec.ts | 22 +- .../permission-guard/user-role.guard.spec.ts | 21 ++ .../permission-guard/user-role.guard.ts | 23 +- .../pwa-install/pwa-install.component.spec.ts | 2 - .../synced-session.service.spec.ts | 26 +-- .../session-service/synced-session.service.ts | 60 ++--- src/app/core/session/session.module.ts | 29 ++- .../core/session/session.service.provider.ts | 66 ------ .../core/sync-status/sync-status.module.ts | 7 +- .../initial-sync-dialog.component.html | 10 - .../initial-sync-dialog.component.ts | 36 --- .../sync-status/sync-status.component.spec.ts | 14 -- .../sync-status/sync-status.component.ts | 71 ++---- .../core/ui/search/search.component.spec.ts | 47 +--- .../empty/application-loading.component.html | 7 + .../empty/application-loading.component.ts | 6 + .../not-found/not-found.component.html | 6 + .../not-found/not-found.component.scss | 4 + .../not-found/not-found.component.spec.ts | 26 +++ .../not-found/not-found.component.ts | 8 + .../dynamic-routing/router.service.spec.ts | 44 +++- .../view/dynamic-routing/router.service.ts | 51 ++--- .../dynamic-routing/view-config.interface.ts | 5 +- src/app/core/view/view.module.ts | 18 +- .../demo-historical-data-generator.ts | 17 +- .../progress-dashboard.component.spec.ts | 7 +- .../features/reporting/query.service.spec.ts | 9 +- src/app/utils/database-testing.module.ts | 25 ++- src/app/utils/mocked-testing.module.ts | 5 + src/main.ts | 8 +- 62 files changed, 717 insertions(+), 1013 deletions(-) delete mode 100644 src/app/core/app-config/app-config.module.ts create mode 100644 src/app/core/config/demo-config-generator.service.ts create mode 100644 src/app/core/database/database.module.ts delete mode 100644 src/app/core/session/session.service.provider.ts delete mode 100644 src/app/core/sync-status/sync-status/initial-sync-dialog.component.html delete mode 100644 src/app/core/sync-status/sync-status/initial-sync-dialog.component.ts create mode 100644 src/app/core/view/dynamic-routing/empty/application-loading.component.html create mode 100644 src/app/core/view/dynamic-routing/empty/application-loading.component.ts create mode 100644 src/app/core/view/dynamic-routing/not-found/not-found.component.html create mode 100644 src/app/core/view/dynamic-routing/not-found/not-found.component.scss create mode 100644 src/app/core/view/dynamic-routing/not-found/not-found.component.spec.ts create mode 100644 src/app/core/view/dynamic-routing/not-found/not-found.component.ts diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 340c5ed29b..857f692546 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -25,7 +25,6 @@ import { waitForAsync, } from "@angular/core/testing"; import { AppComponent } from "./app.component"; -import { ApplicationInitStatus } from "@angular/core"; import { AppModule } from "./app.module"; import { AppConfig } from "./core/app-config/app-config"; import { IAppConfig } from "./core/app-config/app-config.model"; @@ -33,55 +32,40 @@ import { Angulartics2Matomo } from "angulartics2/matomo"; import { Config } from "./core/config/config"; import { USAGE_ANALYTICS_CONFIG_ID } from "./core/analytics/usage-analytics-config"; import { environment } from "../environments/environment"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; import { EntityRegistry } from "./core/entity/database-entity.decorator"; -import { BehaviorSubject } from "rxjs"; -import { SyncState } from "./core/session/session-states/sync-state.enum"; -import { LoginState } from "./core/session/session-states/login-state.enum"; -import { SessionService } from "./core/session/session-service/session.service"; -import { Router } from "@angular/router"; -import { ConfigService } from "./core/config/config.service"; -import { PouchDatabase } from "./core/database/pouch-database"; +import { Subject } from "rxjs"; import { Database } from "./core/database/database"; +import { UpdatedEntity } from "./core/entity/model/entity-update"; +import { EntityMapperService } from "./core/entity/entity-mapper.service"; +import { mockEntityMapper } from "./core/entity/mock-entity-mapper-service"; +import { DemoDataService } from "./core/demo-data/demo-data.service"; +import { SessionType } from "./core/session/session-type"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; describe("AppComponent", () => { let component: AppComponent; let fixture: ComponentFixture; - const syncState = new BehaviorSubject(SyncState.UNSYNCED); - const loginState = new BehaviorSubject(LoginState.LOGGED_OUT); - let mockSessionService: jasmine.SpyObj; + let entityUpdates: Subject>; const mockAppSettings: IAppConfig = { database: { name: "", remote_url: "" }, - session_type: undefined, + session_type: SessionType.local, + demo_mode: false, site_name: "", }; beforeEach( waitForAsync(() => { - mockSessionService = jasmine.createSpyObj( - ["getCurrentUser", "isLoggedIn"], - { - syncState: syncState, - loginState: loginState, - } - ); - mockSessionService.getCurrentUser.and.returnValue({ - name: "test user", - roles: [], - }); AppConfig.settings = mockAppSettings; + const entityMapper = mockEntityMapper(); + entityUpdates = new Subject(); + spyOn(entityMapper, "receiveUpdates").and.returnValue(entityUpdates); TestBed.configureTestingModule({ imports: [AppModule, HttpClientTestingModule], - providers: [ - { provide: AppConfig, useValue: jasmine.createSpyObj(["load"]) }, - { provide: SessionService, useValue: mockSessionService }, - { provide: Database, useValue: PouchDatabase.create() }, - ], + providers: [{ provide: EntityMapperService, useValue: entityMapper }], }).compileComponents(); - TestBed.inject(ApplicationInitStatus); // This ensures that the AppConfig is loaded before test execution spyOn(TestBed.inject(EntityRegistry), "add"); // Prevent error with duplicate registration }) ); @@ -106,18 +90,18 @@ describe("AppComponent", () => { it("should start tracking with config from db", fakeAsync(() => { environment.production = true; // tracking is only active in production mode const testConfig = new Config(Config.CONFIG_KEY, { - "appConfig:usage-analytics": { + [USAGE_ANALYTICS_CONFIG_ID]: { url: "matomo-test-endpoint", site_id: "101", }, }); + entityUpdates.next({ entity: testConfig, type: "new" }); const angulartics = TestBed.inject(Angulartics2Matomo); const startTrackingSpy = spyOn(angulartics, "startTracking"); window["_paq"] = []; createComponent(); tick(); - TestBed.inject(ConfigService).configUpdates.next(testConfig); expect(startTrackingSpy).toHaveBeenCalledTimes(1); expect(window["_paq"]).toContain([ @@ -128,41 +112,16 @@ describe("AppComponent", () => { discardPeriodicTasks(); })); - it("should navigate on same page only when the config changes", fakeAsync(() => { - const routeSpy = spyOn(TestBed.inject(Router), "navigate"); - mockSessionService.isLoggedIn.and.returnValue(true); - createComponent(); - tick(); - expect(routeSpy).toHaveBeenCalledTimes(1); - - const configService = TestBed.inject(ConfigService); - const config = configService.configUpdates.value; - configService.configUpdates.next(config); - tick(); - expect(routeSpy).toHaveBeenCalledTimes(1); - - config.data["someProp"] = "some change"; - configService.configUpdates.next(config); - tick(); - expect(routeSpy).toHaveBeenCalledTimes(2); + it("published the demo data", fakeAsync(() => { + const demoDataService = TestBed.inject(DemoDataService); + spyOn(demoDataService, "publishDemoData").and.callThrough(); + AppConfig.settings.demo_mode = true; + createComponent(); flush(); discardPeriodicTasks(); - })); - - it("should reload the config whenever the sync completes", () => { - const configSpy = spyOn(TestBed.inject(ConfigService), "loadConfig"); - createComponent(); - - expect(configSpy).not.toHaveBeenCalled(); - syncState.next(SyncState.STARTED); - expect(configSpy).not.toHaveBeenCalled(); - - syncState.next(SyncState.COMPLETED); - expect(configSpy).toHaveBeenCalledTimes(1); - - syncState.next(SyncState.COMPLETED); - expect(configSpy).toHaveBeenCalledTimes(2); - }); + expect(demoDataService.publishDemoData).toHaveBeenCalled(); + AppConfig.settings.demo_mode = false; + })); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 9830e2cd8f..044dc16c67 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -21,7 +21,6 @@ import { ConfigService } from "./core/config/config.service"; import { RouterService } from "./core/view/dynamic-routing/router.service"; import { EntityConfigService } from "./core/entity/entity-config.service"; import { SessionService } from "./core/session/session-service/session.service"; -import { SyncState } from "./core/session/session-states/sync-state.enum"; import { ActivatedRoute, Router } from "@angular/router"; import { environment } from "../environments/environment"; import { Child } from "./child-dev-project/children/model/child"; @@ -31,7 +30,6 @@ import { AppConfig } from "./core/app-config/app-config"; import { LoginState } from "./core/session/session-states/login-state.enum"; import { LoggingService } from "./core/logging/logging.service"; import { EntityRegistry } from "./core/entity/database-entity.decorator"; -import { filter } from "rxjs/operators"; /** * Component as the main entry point for the app. @@ -66,21 +64,11 @@ export class AppComponent { // first register to events - // Reload config once the database is synced after someone logged in - this.sessionService.syncState - .pipe(filter((state) => state === SyncState.COMPLETED)) - .subscribe(() => this.configService.loadConfig()); - // Re-trigger services that depend on the config when something changes - let lastConfig: string; - this.configService.configUpdates.subscribe((config) => { + this.configService.configUpdates.subscribe(() => { this.routerService.initRouting(); this.entityConfigService.setupEntitiesFromConfig(); - const configString = JSON.stringify(config); - if (this.sessionService.isLoggedIn() && configString !== lastConfig) { - this.router.navigate([], { relativeTo: this.activatedRoute }); - lastConfig = configString; - } + this.router.navigate([], { relativeTo: this.activatedRoute }); }); // update the user context for remote error logging and tracking and load config initially @@ -89,7 +77,6 @@ export class AppComponent { const username = this.sessionService.getCurrentUser().name; LoggingService.setLoggingContextUser(username); this.analyticsService.setUser(username); - this.configService.loadConfig(); } else { LoggingService.setLoggingContextUser(undefined); this.analyticsService.setUser(undefined); diff --git a/src/app/app.module.ts b/src/app/app.module.ts index bba251dc89..37f21efb34 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -23,7 +23,6 @@ import { HttpClientModule } from "@angular/common/http"; import { AppComponent } from "./app.component"; import { UiModule } from "./core/ui/ui.module"; -import { AppConfigModule } from "./core/app-config/app-config.module"; import { RouteRegistry, routesRegistry, routing } from "./app.routing"; import { AlertsModule } from "./core/alerts/alerts.module"; import { SessionModule } from "./core/session/session.module"; @@ -80,6 +79,8 @@ import { fas } from "@fortawesome/free-solid-svg-icons"; import { far } from "@fortawesome/free-regular-svg-icons"; import { DemoPermissionGeneratorService } from "./core/permissions/demo-permission-generator.service"; import { SupportModule } from "./core/support/support.module"; +import { DemoConfigGeneratorService } from "./core/config/demo-config-generator.service"; +import { DatabaseModule } from "./core/database/database.module"; /** * Main entry point of the application. @@ -106,7 +107,6 @@ import { SupportModule } from "./core/support/support.module"; FormDialogModule, AlertsModule, EntityModule, - AppConfigModule, SessionModule, ConfigModule, UiModule, @@ -150,11 +150,13 @@ import { SupportModule } from "./core/support/support.module"; maxCountAttributes: 5, }), ...DemoPermissionGeneratorService.provider(), + ...DemoConfigGeneratorService.provider(), ]), AttendanceModule, DashboardShortcutWidgetModule, HistoricalDataModule, SupportModule, + DatabaseModule, ], providers: [ { provide: ErrorHandler, useClass: LoggingErrorHandler }, diff --git a/src/app/app.routing.ts b/src/app/app.routing.ts index ac2e502dd2..23b4f30007 100644 --- a/src/app/app.routing.ts +++ b/src/app/app.routing.ts @@ -17,9 +17,10 @@ import { RouterModule, Routes } from "@angular/router"; import { ModuleWithProviders } from "@angular/core"; -import { UserRoleGuard } from "./core/permissions/permission-guard/user-role.guard"; import { ComponentType } from "@angular/cdk/overlay"; import { Registry } from "./core/registry/dynamic-registry"; +import { ApplicationLoadingComponent } from "./core/view/dynamic-routing/empty/application-loading.component"; +import { NotFoundComponent } from "./core/view/dynamic-routing/not-found/not-found.component"; export class RouteRegistry extends Registry> {} export const routesRegistry = new RouteRegistry(); @@ -46,7 +47,6 @@ export const allRoutes: Routes = [ // routes are added dynamically by the RouterService { path: "admin/conflicts", - canActivate: [UserRoleGuard], loadChildren: () => import("./conflict-resolution/conflict-resolution.module").then( (m) => m["ConflictResolutionModule"] @@ -59,7 +59,8 @@ export const allRoutes: Routes = [ (m) => m["ComingSoonModule"] ), }, - { path: "**", redirectTo: "/" }, + { path: "404", component: NotFoundComponent }, + { path: "**", pathMatch: "full", component: ApplicationLoadingComponent }, ]; /** diff --git a/src/app/child-dev-project/children/dashboard-widgets/children-count-dashboard/children-count-dashboard.component.spec.ts b/src/app/child-dev-project/children/dashboard-widgets/children-count-dashboard/children-count-dashboard.component.spec.ts index 2f16be8b5c..a5c3e342f6 100644 --- a/src/app/child-dev-project/children/dashboard-widgets/children-count-dashboard/children-count-dashboard.component.spec.ts +++ b/src/app/child-dev-project/children/dashboard-widgets/children-count-dashboard/children-count-dashboard.component.spec.ts @@ -1,7 +1,6 @@ import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; import { ChildrenCountDashboardComponent } from "./children-count-dashboard.component"; -import { RouterTestingModule } from "@angular/router/testing"; import { Center, Child } from "../../model/child"; import { ConfigurableEnumValue } from "../../../../core/configurable-enum/configurable-enum.interface"; import { FontAwesomeTestingModule } from "@fortawesome/angular-fontawesome/testing"; @@ -11,6 +10,7 @@ import { MockEntityMapperService, } from "../../../../core/entity/mock-entity-mapper-service"; import { ChildrenModule } from "../../children.module"; +import { MockedTestingModule } from "../../../../utils/mocked-testing.module"; describe("ChildrenCountDashboardComponent", () => { let component: ChildrenCountDashboardComponent; @@ -29,7 +29,7 @@ describe("ChildrenCountDashboardComponent", () => { TestBed.configureTestingModule({ imports: [ ChildrenModule, - RouterTestingModule, + MockedTestingModule.withState(), FontAwesomeTestingModule, ], providers: [{ provide: EntityMapperService, useValue: entityMapper }], diff --git a/src/app/child-dev-project/notes/demo-data/demo-note-generator.service.ts b/src/app/child-dev-project/notes/demo-data/demo-note-generator.service.ts index d6bc4f1412..c7e3751d27 100644 --- a/src/app/child-dev-project/notes/demo-data/demo-note-generator.service.ts +++ b/src/app/child-dev-project/notes/demo-data/demo-note-generator.service.ts @@ -10,18 +10,9 @@ import { noteGroupStories } from "./notes_group-stories"; import { centersUnique } from "../../children/demo-data-generators/fixtures/centers"; import { absenceRemarks } from "./remarks"; import moment from "moment"; -import { EntitySchemaService } from "../../../core/entity/schema/entity-schema.service"; -import { - ATTENDANCE_STATUS_CONFIG_ID, - AttendanceLogicalStatus, - AttendanceStatusType, -} from "../../attendance/model/attendance-status"; -import { ConfigService } from "../../../core/config/config.service"; -import { - CONFIGURABLE_ENUM_CONFIG_PREFIX, - ConfigurableEnumConfig, -} from "../../../core/configurable-enum/configurable-enum.interface"; +import { AttendanceLogicalStatus } from "../../attendance/model/attendance-status"; import { DemoUserGeneratorService } from "../../../core/user/demo-user-generator.service"; +import { defaultAttendanceStatusTypes } from "../../../core/config/default-config/default-attendance-status-types"; export class DemoNoteConfig { minNotesPerChild: number; @@ -52,20 +43,12 @@ export class DemoNoteGeneratorService extends DemoDataGenerator { ]; } - private availableStatusTypes: AttendanceStatusType[]; - constructor( private config: DemoNoteConfig, private demoChildren: DemoChildGenerator, - private demoUsers: DemoUserGeneratorService, - private schemaService: EntitySchemaService, - private configService: ConfigService + private demoUsers: DemoUserGeneratorService ) { super(); - - this.availableStatusTypes = this.configService.getConfig< - ConfigurableEnumConfig - >(CONFIGURABLE_ENUM_CONFIG_PREFIX + ATTENDANCE_STATUS_CONFIG_ID); } public generateEntities(): Note[] { @@ -113,15 +96,10 @@ export class DemoNoteGeneratorService extends DemoDataGenerator { } private generateNoteForChild(child: Child, date?: Date): Note { - let note = new Note(); + const note = new Note(); const selectedStory = faker.random.arrayElement(noteIndividualStories); Object.assign(note, selectedStory); - // transform to ensure the category object is loaded from the generic config - note = this.schemaService.transformDatabaseToEntityFormat( - note, - Note.schema - ); note.addChild(child.getId()); note.authors = [faker.random.arrayElement(this.demoUsers.entities).getId()]; @@ -151,27 +129,22 @@ export class DemoNoteGeneratorService extends DemoDataGenerator { } private generateGroupNote(children: Child[]) { - let note = new Note(); + const note = new Note(); const selectedStory = faker.random.arrayElement(noteGroupStories); Object.assign(note, selectedStory); - // transform to ensure the category object is loaded from the generic config - note = this.schemaService.transformDatabaseToEntityFormat( - note, - Note.schema - ); note.children = children.map((c) => c.getId()); children.forEach((child) => { const attendance = note.getAttendance(child.getId()); // get an approximate presence of 85% if (faker.datatype.number(100) <= 15) { - attendance.status = this.availableStatusTypes.find( + attendance.status = defaultAttendanceStatusTypes.find( (t) => t.countAs === AttendanceLogicalStatus.ABSENT ); attendance.remarks = faker.random.arrayElement(absenceRemarks); } else { - attendance.status = this.availableStatusTypes.find( + attendance.status = defaultAttendanceStatusTypes.find( (t) => t.countAs === AttendanceLogicalStatus.PRESENT ); } diff --git a/src/app/child-dev-project/notes/demo-data/notes_group-stories.ts b/src/app/child-dev-project/notes/demo-data/notes_group-stories.ts index 4a8cd445d9..f515c10452 100644 --- a/src/app/child-dev-project/notes/demo-data/notes_group-stories.ts +++ b/src/app/child-dev-project/notes/demo-data/notes_group-stories.ts @@ -1,8 +1,9 @@ import { warningLevels } from "../../warning-levels"; +import { defaultInteractionTypes } from "../../../core/config/default-config/default-interaction-types"; export const noteGroupStories = [ { - category: "GUARDIAN_MEETING", + category: defaultInteractionTypes.find((t) => t.id === "GUARDIAN_MEETING"), warningLevel: warningLevels.find((level) => level.id === "OK"), subject: $localize`:Note demo subject:Guardians Meeting`, text: $localize`:Note demo text: @@ -10,7 +11,7 @@ export const noteGroupStories = [ `, }, { - category: "GUARDIAN_MEETING", + category: defaultInteractionTypes.find((t) => t.id === "GUARDIAN_MEETING"), warningLevel: warningLevels.find((level) => level.id === "OK"), subject: $localize`:Note demo subject:Guardians Meeting`, text: $localize`:Note demo text: @@ -19,7 +20,7 @@ export const noteGroupStories = [ }, { - category: "CHILDREN_MEETING", + category: defaultInteractionTypes.find((t) => t.id === "CHILDREN_MEETING"), warningLevel: warningLevels.find((level) => level.id === "OK"), subject: $localize`:Note demo subject:Children Meeting`, text: $localize`:Note demo text: @@ -27,7 +28,7 @@ export const noteGroupStories = [ `, }, { - category: "CHILDREN_MEETING", + category: defaultInteractionTypes.find((t) => t.id === "CHILDREN_MEETING"), warningLevel: warningLevels.find((level) => level.id === "OK"), subject: $localize`:Note demo subject:Children Meeting`, text: $localize`:Note demo text: @@ -35,7 +36,7 @@ export const noteGroupStories = [ `, }, { - category: "CHILDREN_MEETING", + category: defaultInteractionTypes.find((t) => t.id === "CHILDREN_MEETING"), warningLevel: warningLevels.find((level) => level.id === "OK"), subject: $localize`:Note demo subject:Drug Prevention Workshop`, text: $localize`:Note demo text: diff --git a/src/app/child-dev-project/notes/demo-data/notes_individual-stories.ts b/src/app/child-dev-project/notes/demo-data/notes_individual-stories.ts index 9fbdfaf430..7f62c29182 100644 --- a/src/app/child-dev-project/notes/demo-data/notes_individual-stories.ts +++ b/src/app/child-dev-project/notes/demo-data/notes_individual-stories.ts @@ -1,8 +1,9 @@ import { warningLevels } from "../../warning-levels"; +import { defaultInteractionTypes } from "../../../core/config/default-config/default-interaction-types"; export const noteIndividualStories = [ { - category: "HOME_VISIT", + category: defaultInteractionTypes.find((t) => t.id === "HOME_VISIT"), warningLevel: warningLevels.find((level) => level.id === "WARNING"), subject: $localize`:Note demo subject:Mother sick`, text: $localize`:Note demo text: @@ -11,7 +12,7 @@ export const noteIndividualStories = [ `, }, { - category: "GUARDIAN_TALK", + category: defaultInteractionTypes.find((t) => t.id === "GUARDIAN_TALK"), warningLevel: warningLevels.find((level) => level.id === "WARNING"), subject: $localize`:Note demo subject:Discussed school change`, text: $localize`:Note demo text: @@ -21,7 +22,7 @@ export const noteIndividualStories = [ }, { - category: "PHONE_CALL", + category: defaultInteractionTypes.find((t) => t.id === "PHONE_CALL"), warningLevel: warningLevels.find((level) => level.id === "OK"), subject: $localize`:Note demo subject:Follow up for school absence`, text: $localize`:Note demo text: @@ -29,7 +30,7 @@ export const noteIndividualStories = [ `, }, { - category: "PHONE_CALL", + category: defaultInteractionTypes.find((t) => t.id === "PHONE_CALL"), warningLevel: warningLevels.find((level) => level.id === "OK"), subject: $localize`:Note demo subject:Absent because ill`, text: $localize`:Note demo text: @@ -37,7 +38,7 @@ export const noteIndividualStories = [ `, }, { - category: "PHONE_CALL", + category: defaultInteractionTypes.find((t) => t.id === "PHONE_CALL"), warningLevel: warningLevels.find((level) => level.id === "URGENT"), subject: $localize`:Note demo subject:Absence without information`, text: $localize`:Note demo text: @@ -46,7 +47,7 @@ export const noteIndividualStories = [ `, }, { - category: "VISIT", + category: defaultInteractionTypes.find((t) => t.id === "VISIT"), warningLevel: warningLevels.find((level) => level.id === "OK"), subject: $localize`:Note demo subject:School is happy about progress`, text: $localize`:Note demo text: @@ -55,7 +56,7 @@ export const noteIndividualStories = [ `, }, { - category: "COACHING_TALK", + category: defaultInteractionTypes.find((t) => t.id === "COACHING_TALK"), warningLevel: warningLevels.find((level) => level.id === "WARNING"), subject: $localize`:Note demo subject:Needs to work more for school`, text: $localize`:Note demo text: @@ -64,7 +65,7 @@ export const noteIndividualStories = [ `, }, { - category: "INCIDENT", + category: defaultInteractionTypes.find((t) => t.id === "INCIDENT"), warningLevel: warningLevels.find((level) => level.id === "URGENT"), subject: $localize`:Note demo subject:Fight at school`, text: $localize`:Note demo text: @@ -73,7 +74,7 @@ export const noteIndividualStories = [ `, }, { - category: "DISCUSSION", + category: defaultInteractionTypes.find((t) => t.id === "DISCUSSION"), warningLevel: warningLevels.find((level) => level.id === "OK"), subject: $localize`:Note demo subject:Special help for family`, text: $localize`:Note demo text: @@ -82,7 +83,7 @@ export const noteIndividualStories = [ `, }, { - category: "DISCUSSION", + category: defaultInteractionTypes.find((t) => t.id === "DISCUSSION"), warningLevel: warningLevels.find((level) => level.id === "OK"), subject: $localize`:Note demo subject:Chance to repeat class`, text: $localize`:Note demo text: @@ -92,7 +93,7 @@ export const noteIndividualStories = [ `, }, { - category: "CHILD_TALK", + category: defaultInteractionTypes.find((t) => t.id === "CHILD_TALK"), warningLevel: warningLevels.find((level) => level.id === "WARNING"), subject: $localize`:Note demo subject:Distracted in class`, text: $localize`:Note demo text: @@ -101,7 +102,7 @@ export const noteIndividualStories = [ `, }, { - category: "CHILD_TALK", + category: defaultInteractionTypes.find((t) => t.id === "CHILD_TALK"), warningLevel: warningLevels.find((level) => level.id === "WARNING"), subject: $localize`:Note demo subject:Disturbing class`, text: $localize`:Note demo text: diff --git a/src/app/core/admin/admin/admin.component.ts b/src/app/core/admin/admin/admin.component.ts index 5b7d3ecf84..ce00938358 100644 --- a/src/app/core/admin/admin/admin.component.ts +++ b/src/app/core/admin/admin/admin.component.ts @@ -73,8 +73,8 @@ export class AdminComponent implements OnInit { this.startDownload(csv, "text/csv", "export.csv"); } - async downloadConfigClick() { - const configString = await this.configService.exportConfig(); + downloadConfigClick() { + const configString = this.configService.exportConfig(); this.startDownload(configString, "text/json", "config.json"); } diff --git a/src/app/core/analytics/analytics.service.spec.ts b/src/app/core/analytics/analytics.service.spec.ts index ff47c8d450..5dc728491b 100644 --- a/src/app/core/analytics/analytics.service.spec.ts +++ b/src/app/core/analytics/analytics.service.spec.ts @@ -8,14 +8,14 @@ import { UsageAnalyticsConfig } from "./usage-analytics-config"; import { Angulartics2Matomo } from "angulartics2/matomo"; import { AppConfig } from "../app-config/app-config"; import { IAppConfig } from "../app-config/app-config.model"; -import { BehaviorSubject } from "rxjs"; +import { Subject } from "rxjs"; import { Config } from "../config/config"; describe("AnalyticsService", () => { let service: AnalyticsService; let mockConfigService: jasmine.SpyObj; - const configUpdates = new BehaviorSubject(new Config()); + const configUpdates = new Subject(); let mockMatomo: jasmine.SpyObj; beforeEach(() => { @@ -75,7 +75,7 @@ describe("AnalyticsService", () => { mockConfigService.getConfig.and.returnValue(testAnalyticsConfig); service.init(); - mockConfigService.configUpdates.next(new Config()); + configUpdates.next(new Config()); expect(window["_paq"]).toContain([ "setSiteId", @@ -91,7 +91,7 @@ describe("AnalyticsService", () => { }; service.init(); mockConfigService.getConfig.and.returnValue(testAnalyticsConfig); - mockConfigService.configUpdates.next(new Config()); + configUpdates.next(new Config()); expect(window["_paq"]).toContain([ "setTrackerUrl", @@ -104,7 +104,7 @@ describe("AnalyticsService", () => { url: "test-endpoint/", }; mockConfigService.getConfig.and.returnValue(testAnalyticsConfig2); - mockConfigService.configUpdates.next(new Config()); + configUpdates.next(new Config()); expect(window["_paq"]).toContain([ "setTrackerUrl", diff --git a/src/app/core/app-config/app-config.module.ts b/src/app/core/app-config/app-config.module.ts deleted file mode 100644 index 4084db4397..0000000000 --- a/src/app/core/app-config/app-config.module.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * This file is part of ndb-core. - * - * ndb-core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * ndb-core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with ndb-core. If not, see . - */ - -import { APP_INITIALIZER, NgModule } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { AppConfig } from "./app-config"; -import { HttpClientModule } from "@angular/common/http"; - -/** - * Management of central configuration for the app that can a set by an administrator independent of code - * in the assets/config.json file. - * - * Just import this module in your root module to ensure the AppConfig is properly initialized on startup. - * - * You can then use the static `AppConfig.settings` object (which exactly represents the contents of the config.json) - * to access the configuration values without need for dependency injection of a service. - */ -@NgModule({ - imports: [CommonModule, HttpClientModule], - declarations: [], - providers: [ - AppConfig, - { - provide: APP_INITIALIZER, - useFactory: initializeAppConfig, - deps: [AppConfig], - multi: true, - }, - ], -}) -export class AppConfigModule {} - -/** - * Factory method for APP_INITIALIZER to load essential things before any other modules. - * This is required to ensure the AppConfig.settings are available before other code needs it. - * - * @param appConfig The AppConfig service (through dependency injection) - */ -export function initializeAppConfig(appConfig: AppConfig): () => Promise { - return () => appConfig.load(); -} diff --git a/src/app/core/app-config/app-config.ts b/src/app/core/app-config/app-config.ts index 2d904ebbe9..11d94cd813 100644 --- a/src/app/core/app-config/app-config.ts +++ b/src/app/core/app-config/app-config.ts @@ -15,9 +15,7 @@ * along with ndb-core. If not, see . */ -import { Injectable } from "@angular/core"; import { IAppConfig } from "./app-config.model"; -import { HttpClient } from "@angular/common/http"; /** * Central app configuration. @@ -35,28 +33,24 @@ import { HttpClient } from "@angular/common/http"; * // just directly use AppConfig and let your IDE add an "import" statement to the file * // no need for dependency injection here */ -@Injectable() export class AppConfig { /** settings for the app */ static settings: IAppConfig; /** file location of the config file to be created by the administrator */ - private readonly CONFIG_FILE = "assets/config.json"; + private static readonly CONFIG_FILE = "assets/config.json"; /** fallback file location of the config that is part of the project already if the "real" config file isn't found */ - private readonly DEFAULT_CONFIG_FILE = "assets/config.default.json"; - - constructor(private http: HttpClient) {} + private static readonly DEFAULT_CONFIG_FILE = "assets/config.default.json"; /** * Load the config file into the `AppConfig.settings` so they can be used synchronously anywhere in the code after that. * * If the config file does not exist, uses the default config as a fallback. */ - load(): Promise { - return this.loadAppConfigJson(this.CONFIG_FILE).then( - (result) => result, - () => this.loadAppConfigJson(this.DEFAULT_CONFIG_FILE) + static load(): Promise { + return this.loadAppConfigJson(this.CONFIG_FILE).catch(() => + this.loadAppConfigJson(this.DEFAULT_CONFIG_FILE) ); } @@ -68,22 +62,18 @@ export class AppConfig { * * @param jsonFileLocation The file path of the json file to be loaded as config */ - private loadAppConfigJson(jsonFileLocation: string): Promise { - return new Promise((resolve, reject) => { - this.http - .get(jsonFileLocation) - .toPromise() - .then((result) => { - AppConfig.settings = result; - resolve(); - }) - .catch((response: any) => { - reject( - `Could not load file '${jsonFileLocation}': ${JSON.stringify( - response - )}` - ); - }); - }); + private static loadAppConfigJson( + jsonFileLocation: string + ): Promise { + return fetch(jsonFileLocation) + .then((result) => result.json()) + .then((result: IAppConfig) => (AppConfig.settings = result)) + .catch((response: any) => { + throw new Error( + `Could not load file '${jsonFileLocation}': ${JSON.stringify( + response + )}` + ); + }); } } diff --git a/src/app/core/config/config-fix.ts b/src/app/core/config/config-fix.ts index 6c64ee8a99..ac9ae6071f 100644 --- a/src/app/core/config/config-fix.ts +++ b/src/app/core/config/config-fix.ts @@ -306,7 +306,6 @@ export const defaultJsonConfig = { "permittedUserRoles": ["admin_app"] }, "view:admin/conflicts": { - "component": "ConflictResolution", "permittedUserRoles": ["admin_app"], "lazyLoaded": true }, diff --git a/src/app/core/config/config.service.spec.ts b/src/app/core/config/config.service.spec.ts index 05b09e2062..662fd5bfab 100644 --- a/src/app/core/config/config.service.spec.ts +++ b/src/app/core/config/config.service.spec.ts @@ -2,18 +2,24 @@ import { fakeAsync, TestBed, tick } from "@angular/core/testing"; import { ConfigService } from "./config.service"; import { EntityMapperService } from "../entity/entity-mapper.service"; import { Config } from "./config"; -import { defaultJsonConfig } from "./config-fix"; +import { Subject } from "rxjs"; +import { UpdatedEntity } from "../entity/model/entity-update"; +import { take } from "rxjs/operators"; describe("ConfigService", () => { let service: ConfigService; - const entityMapper: jasmine.SpyObj = jasmine.createSpyObj( - "entityMapper", - ["load", "save"] - ); + let entityMapper: jasmine.SpyObj; + const updateSubject = new Subject>(); beforeEach(() => { + entityMapper = jasmine.createSpyObj(["load", "save", "receiveUpdates"]); + entityMapper.receiveUpdates.and.returnValue(updateSubject); + entityMapper.load.and.rejectWith(); TestBed.configureTestingModule({ - providers: [{ provide: EntityMapperService, useValue: entityMapper }], + providers: [ + { provide: EntityMapperService, useValue: entityMapper }, + ConfigService, + ], }); service = TestBed.inject(ConfigService); }); @@ -23,25 +29,30 @@ describe("ConfigService", () => { }); it("should load the config from the entity mapper", fakeAsync(() => { - const testConfig: Config = new Config(); + const testConfig = new Config(); testConfig.data = { testKey: "testValue" }; - entityMapper.load.and.returnValue(Promise.resolve(testConfig)); + entityMapper.load.and.resolveTo(testConfig); + service.loadConfig(); expect(entityMapper.load).toHaveBeenCalled(); tick(); expect(service.getConfig("testKey")).toEqual("testValue"); })); - it("should use the default config when none is loaded", fakeAsync(() => { - const defaultConfig = Object.keys(defaultJsonConfig).map((key) => { - defaultJsonConfig[key]._id = key; - return defaultJsonConfig[key]; - }); + it("should emit the config once it is loaded", fakeAsync(() => { entityMapper.load.and.rejectWith("No config found"); + const configLoaded = service.configUpdates.pipe(take(1)).toPromise(); + service.loadConfig(); tick(); - const configAfter = service.getAllConfigs(""); - expect(configAfter).toEqual(defaultConfig); + expect(() => service.getConfig("testKey")).toThrowError(); + + const testConfig = new Config(); + testConfig.data = { testKey: "testValue" }; + updateSubject.next({ type: "new", entity: testConfig }); + + expect(service.getConfig("testKey")).toBe("testValue"); + return expectAsync(configLoaded).toBeResolvedTo(testConfig); })); it("should correctly return prefixed fields", fakeAsync(() => { @@ -75,27 +86,17 @@ describe("ConfigService", () => { const newConfig = { test: "data" }; service.saveConfig(newConfig); expect(entityMapper.save).toHaveBeenCalled(); - expect(entityMapper.save.calls.mostRecent().args[0]).toBeInstanceOf(Config); - expect( - (entityMapper.save.calls.mostRecent().args[0] as Config).data - ).toEqual({ test: "data" }); + const lastCall = entityMapper.save.calls.mostRecent().args[0] as Config; + expect(lastCall).toBeInstanceOf(Config); + expect(lastCall.data).toEqual({ test: "data" }); }); - it("should create export config string", async () => { + it("should create export config string", () => { const config = new Config(); config.data = { first: "foo", second: "bar" }; const expected = JSON.stringify(config.data); - entityMapper.load.and.returnValue(Promise.resolve(config)); - const result = await service.exportConfig(); + updateSubject.next({ entity: config, type: "update" }); + const result = service.exportConfig(); expect(result).toEqual(expected); }); - - it("should emit new value", fakeAsync(() => { - spyOn(service.configUpdates, "next"); - entityMapper.load.and.returnValue(Promise.resolve(new Config())); - expect(service.configUpdates.next).not.toHaveBeenCalled(); - service.loadConfig(); - tick(); - expect(service.configUpdates.next).toHaveBeenCalled(); - })); }); diff --git a/src/app/core/config/config.service.ts b/src/app/core/config/config.service.ts index dbcedf7e4c..17b318fbc1 100644 --- a/src/app/core/config/config.service.ts +++ b/src/app/core/config/config.service.ts @@ -1,70 +1,64 @@ -import { Injectable, Optional } from "@angular/core"; +import { Injectable } from "@angular/core"; import { EntityMapperService } from "../entity/entity-mapper.service"; import { Config } from "./config"; -import { LoggingService } from "../logging/logging.service"; -import { BehaviorSubject } from "rxjs"; -import { defaultJsonConfig } from "./config-fix"; +import { Observable, ReplaySubject } from "rxjs"; import { CONFIGURABLE_ENUM_CONFIG_PREFIX, ConfigurableEnumConfig, ConfigurableEnumValue, } from "../configurable-enum/configurable-enum.interface"; +import { filter } from "rxjs/operators"; +import { mockEntityMapper } from "../entity/mock-entity-mapper-service"; +import { defaultJsonConfig } from "./config-fix"; /** * Access dynamic app configuration retrieved from the database * that defines how the interface and data models should look. */ -@Injectable({ - providedIn: "root", -}) +@Injectable() export class ConfigService { /** * Subscribe to receive the current config and get notified whenever the config is updated. */ - public configUpdates: BehaviorSubject; + private _configUpdates = new ReplaySubject(1); + private currentConfig: Config; - private get configData(): any { - return this.configUpdates.value.data; + get configUpdates(): Observable { + return this._configUpdates.asObservable(); } - constructor( - private entityMapper: EntityMapperService, - @Optional() private loggingService?: LoggingService - ) { - const defaultConfig = JSON.parse(JSON.stringify(defaultJsonConfig)); - this.configUpdates = new BehaviorSubject( - new Config(Config.CONFIG_KEY, defaultConfig) - ); + constructor(private entityMapper: EntityMapperService) { + this.loadConfig(); + this.entityMapper + .receiveUpdates(Config) + .pipe(filter(({ entity }) => entity.getId() === Config.CONFIG_KEY)) + .subscribe(({ entity }) => this.updateConfigIfChanged(entity)); } - public async loadConfig(): Promise { - this.configUpdates.next(await this.getConfigOrDefault()); - return this.configUpdates.value; + async loadConfig(): Promise { + this.entityMapper + .load(Config, Config.CONFIG_KEY) + .then((config) => this.updateConfigIfChanged(config)) + .catch(() => {}); } - private getConfigOrDefault(): Promise { - return this.entityMapper.load(Config, Config.CONFIG_KEY).catch(() => { - this.loggingService.info( - "No configuration found in the database, using default one" - ); - const defaultConfig = JSON.parse(JSON.stringify(defaultJsonConfig)); - return new Config(Config.CONFIG_KEY, defaultConfig); - }); + private updateConfigIfChanged(config: Config) { + if (!this.currentConfig || config._rev !== this.currentConfig?._rev) { + this.currentConfig = config; + this._configUpdates.next(config); + } } - public async saveConfig(config: any): Promise { - this.configUpdates.next(new Config(Config.CONFIG_KEY, config)); - await this.entityMapper.save(this.configUpdates.value, true); - return this.configUpdates.value; + public saveConfig(config: any): Promise { + return this.entityMapper.save(new Config(Config.CONFIG_KEY, config), true); } - public async exportConfig(): Promise { - const config = await this.getConfigOrDefault(); - return JSON.stringify(config.data); + public exportConfig(): string { + return JSON.stringify(this.currentConfig.data); } public getConfig(id: string): T { - return this.configData[id]; + return this.currentConfig.data[id]; } /** @@ -82,20 +76,20 @@ export class ConfigService { public getAllConfigs(prefix: string): T[] { const matchingConfigs = []; - for (const id of Object.keys(this.configData)) { + for (const id of Object.keys(this.currentConfig.data)) { if (id.startsWith(prefix)) { - this.configData[id]._id = id; - matchingConfigs.push(this.configData[id]); + this.currentConfig.data[id]._id = id; + matchingConfigs.push(this.currentConfig.data[id]); } } return matchingConfigs; } } -export function createTestingConfigService(configsObject: any): ConfigService { - const configService = new ConfigService(null); - configService.configUpdates.next( - new Config(Config.CONFIG_KEY, configsObject) - ); +export function createTestingConfigService( + configsObject: any = defaultJsonConfig +): ConfigService { + const configService = new ConfigService(mockEntityMapper()); + configService["currentConfig"] = new Config(Config.CONFIG_KEY, configsObject); return configService; } diff --git a/src/app/core/config/demo-config-generator.service.ts b/src/app/core/config/demo-config-generator.service.ts new file mode 100644 index 0000000000..0f3142f6eb --- /dev/null +++ b/src/app/core/config/demo-config-generator.service.ts @@ -0,0 +1,22 @@ +import { Injectable } from "@angular/core"; +import { DemoDataGenerator } from "../demo-data/demo-data-generator"; +import { Config } from "./config"; +import { DatabaseRules } from "../permissions/permission-types"; +import { defaultJsonConfig } from "./config-fix"; + +@Injectable() +export class DemoConfigGeneratorService extends DemoDataGenerator { + static provider() { + return [ + { + provide: DemoConfigGeneratorService, + useClass: DemoConfigGeneratorService, + }, + ]; + } + + protected generateEntities(): Config[] { + const defaultConfig = JSON.parse(JSON.stringify(defaultJsonConfig)); + return [new Config(Config.CONFIG_KEY, defaultConfig)]; + } +} diff --git a/src/app/core/database/database.module.ts b/src/app/core/database/database.module.ts new file mode 100644 index 0000000000..03f8886ad1 --- /dev/null +++ b/src/app/core/database/database.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from "@angular/core"; +import { PouchDatabase } from "./pouch-database"; +import { Database } from "./database"; + +/** + * This module provides the `Database` injectable. + * Inject `Database` in you class if you need to directly access this service. + * + * Currently, this is always a `PouchDatabase` but this might change in the future. + * Therefore, the `PouchDatabase` should only be injected with special care (and when it's really, really necessary). + */ +@NgModule({ + providers: [PouchDatabase, { provide: Database, useExisting: PouchDatabase }], +}) +export class DatabaseModule {} diff --git a/src/app/core/database/database.ts b/src/app/core/database/database.ts index e3c56d764c..9b8be04900 100644 --- a/src/app/core/database/database.ts +++ b/src/app/core/database/database.ts @@ -20,9 +20,6 @@ import { Observable } from "rxjs"; /** * An implementation of this abstract class provides functions for direct database access. * This interface is an extension of the [PouchDB API](https://pouchdb.com/api.html). - * - * A `Database` instance is injected into the app through the {@link databaseServiceProvider} - * with the help of the {@link SessionService}. */ export abstract class Database { /** diff --git a/src/app/core/database/pouch-database.spec.ts b/src/app/core/database/pouch-database.spec.ts index d34a814482..ec86def37c 100644 --- a/src/app/core/database/pouch-database.spec.ts +++ b/src/app/core/database/pouch-database.spec.ts @@ -149,7 +149,7 @@ describe("PouchDatabase tests", () => { const spyOnQuery = spyOn(database, "query").and.resolveTo(); await database.saveDatabaseIndex(testIndex); - expect(database.put).toHaveBeenCalledWith(testIndex); + expect(database.put).toHaveBeenCalledWith(testIndex, true); // expect all indices to be queried expect(spyOnQuery).toHaveBeenCalledTimes(2); @@ -170,11 +170,14 @@ describe("PouchDatabase tests", () => { const spyOnQuery = spyOn(database, "query").and.resolveTo(); await database.saveDatabaseIndex(testIndex); - expect(database.put).toHaveBeenCalledWith({ - _id: testIndex._id, - _rev: existingIndex._rev, - views: testIndex.views, - }); + expect(database.put).toHaveBeenCalledWith( + { + _id: testIndex._id, + _rev: existingIndex._rev, + views: testIndex.views, + }, + true + ); // expect all indices to be queried expect(spyOnQuery).toHaveBeenCalledTimes(2); diff --git a/src/app/core/database/pouch-database.ts b/src/app/core/database/pouch-database.ts index 15f79ec8e3..5d34930fd7 100644 --- a/src/app/core/database/pouch-database.ts +++ b/src/app/core/database/pouch-database.ts @@ -21,8 +21,8 @@ import PouchDB from "pouchdb-browser"; import memory from "pouchdb-adapter-memory"; import { PerformanceAnalysisLogging } from "../../utils/performance-analysis-logging"; import { Injectable } from "@angular/core"; -import { fromEvent, Observable } from "rxjs"; -import { filter, map } from "rxjs/operators"; +import { Observable, Subject } from "rxjs"; +import { filter } from "rxjs/operators"; /** * Wrapper for a PouchDB instance to decouple the code from @@ -57,7 +57,9 @@ export class PouchDatabase extends Database { * This change can come from the current user or remotely from the (live) synchronization * @private */ - private changesFeed: Observable; + private changesFeed: Subject; + + private databaseInitialized = new Subject(); /** * Create a PouchDB database manager. @@ -75,6 +77,7 @@ export class PouchDatabase extends Database { initInMemoryDB(dbName = "in-memory-database"): PouchDatabase { PouchDB.plugin(memory); this.pouchDB = new PouchDB(dbName, { adapter: "memory" }); + this.databaseInitialized.complete(); return this; } @@ -89,9 +92,15 @@ export class PouchDatabase extends Database { options?: PouchDB.Configuration.DatabaseConfiguration ): PouchDatabase { this.pouchDB = new PouchDB(dbName, options); + this.databaseInitialized.complete(); return this; } + async getPouchDBOnceReady(): Promise { + await this.databaseInitialized.toPromise(); + return this.pouchDB; + } + /** * Get the actual instance of the PouchDB */ @@ -111,15 +120,17 @@ export class PouchDatabase extends Database { options: GetOptions = {}, returnUndefined?: boolean ): Promise { - return this.pouchDB.get(id, options).catch((err) => { - if (err.status === 404) { - this.loggingService.debug("Doc not found in database: " + id); - if (returnUndefined) { - return undefined; + return this.getPouchDBOnceReady() + .then((pouchDB) => pouchDB.get(id, options)) + .catch((err) => { + if (err.status === 404) { + this.loggingService.debug("Doc not found in database: " + id); + if (returnUndefined) { + return undefined; + } } - } - throw new DatabaseException(err); - }); + throw new DatabaseException(err); + }); } /** @@ -133,8 +144,8 @@ export class PouchDatabase extends Database { * @param options PouchDB options object as in the normal PouchDB library */ allDocs(options?: GetAllOptions) { - return this.pouchDB - .allDocs(options) + return this.getPouchDBOnceReady() + .then((pouchDB) => pouchDB.allDocs(options)) .then((result) => result.rows.map((row) => row.doc)) .catch((err) => { throw new DatabaseException(err); @@ -153,13 +164,15 @@ export class PouchDatabase extends Database { object._rev = undefined; } - return this.pouchDB.put(object).catch((err) => { - if (err.status === 409) { - return this.resolveConflict(object, forceOverwrite, err); - } else { - throw new DatabaseException(err); - } - }); + return this.getPouchDBOnceReady() + .then((pouchDB) => pouchDB.put(object)) + .catch((err) => { + if (err.status === 409) { + return this.resolveConflict(object, forceOverwrite, err); + } else { + throw new DatabaseException(err); + } + }); } /** @@ -173,7 +186,8 @@ export class PouchDatabase extends Database { objects.forEach((obj) => (obj._rev = undefined)); } - const results = await this.pouchDB.bulkDocs(objects); + const pouchDB = await this.getPouchDBOnceReady(); + const results = await pouchDB.bulkDocs(objects); for (let i = 0; i < results.length; i++) { // Check if document update conflicts happened in the request @@ -196,9 +210,11 @@ export class PouchDatabase extends Database { * @param object The document to be deleted (usually this object must at least contain the _id and _rev) */ remove(object: any) { - return this.pouchDB.remove(object).catch((err) => { - throw new DatabaseException(err); - }); + return this.getPouchDBOnceReady() + .then((pouchDB) => pouchDB.remove(object)) + .catch((err) => { + throw new DatabaseException(err); + }); } /** @@ -206,22 +222,9 @@ export class PouchDatabase extends Database { * Returns true if there are no documents in the database */ isEmpty(): Promise { - return this.pouchDB.info().then((res) => res.doc_count === 0); - } - - /** - * Sync the local database with a remote database. - * See {@Link https://pouchdb.com/guides/replication.html} - * @param remoteDatabase the PouchDB instance of the remote database - */ - sync(remoteDatabase) { - return this.pouchDB - .sync(remoteDatabase, { - batch_size: 500, - }) - .catch((err) => { - throw new DatabaseException(err); - }); + return this.getPouchDBOnceReady() + .then((pouchDB) => pouchDB.info()) + .then((res) => res.doc_count === 0); } /** @@ -231,15 +234,16 @@ export class PouchDatabase extends Database { */ changes(prefix: string): Observable { if (!this.changesFeed) { - // Only maintain one changes feed for the whole app live-cycle - this.changesFeed = fromEvent( - this.pouchDB.changes({ - live: true, - since: "now", - include_docs: true, - }), - "change" - ).pipe(map((change) => change[0].doc)); + this.changesFeed = new Subject(); + this.getPouchDBOnceReady().then((pouchDB) => + pouchDB + .changes({ + live: true, + since: "now", + include_docs: true, + }) + .addListener("change", (change) => this.changesFeed.next(change.doc)) + ); } return this.changesFeed.pipe(filter((doc) => doc._id.startsWith(prefix))); } @@ -249,7 +253,9 @@ export class PouchDatabase extends Database { */ async destroy(): Promise { await Promise.all(this.indexPromises); - return this.pouchDB.destroy(); + if (this.pouchDB) { + return this.pouchDB.destroy(); + } } /** @@ -266,9 +272,11 @@ export class PouchDatabase extends Database { fun: string | ((doc: any, emit: any) => void), options: QueryOptions ): Promise { - return this.pouchDB.query(fun, options).catch((err) => { - throw new DatabaseException(err); - }); + return this.getPouchDBOnceReady() + .then((pouchDB) => pouchDB.query(fun, options)) + .catch((err) => { + throw new DatabaseException(err); + }); } /** @@ -300,7 +308,7 @@ export class PouchDatabase extends Database { return; } - await this.put(designDoc); + await this.put(designDoc, true); await this.prebuildViewsOfDesignDoc(designDoc); } diff --git a/src/app/core/demo-data/demo-data-initializer.service.spec.ts b/src/app/core/demo-data/demo-data-initializer.service.spec.ts index b7cd6e0ddc..9c4dfbc118 100644 --- a/src/app/core/demo-data/demo-data-initializer.service.spec.ts +++ b/src/app/core/demo-data/demo-data-initializer.service.spec.ts @@ -2,7 +2,6 @@ import { fakeAsync, TestBed, tick } from "@angular/core/testing"; import { DemoDataInitializerService } from "./demo-data-initializer.service"; import { DemoDataService } from "./demo-data.service"; -import { SessionService } from "../session/session-service/session.service"; import { DemoUserGeneratorService } from "../user/demo-user-generator.service"; import { LocalSession } from "../session/session-service/local-session"; import { DatabaseUser } from "../session/session-service/local-user"; @@ -41,8 +40,6 @@ describe("DemoDataInitializerService", () => { ["login", "saveUser", "getCurrentUser"], { loginState: loginState } ); - // @ts-ignore this makes the spy pass the instanceof check - mockSessionService.__proto__ = LocalSession.prototype; TestBed.configureTestingModule({ providers: [ @@ -50,7 +47,7 @@ describe("DemoDataInitializerService", () => { { provide: MatDialog, useValue: mockDialog }, { provide: Database, useClass: PouchDatabase }, { provide: DemoDataService, useValue: mockDemoDataService }, - { provide: SessionService, useValue: mockSessionService }, + { provide: LocalSession, useValue: mockSessionService }, ], }); service = TestBed.inject(DemoDataInitializerService); diff --git a/src/app/core/demo-data/demo-data-initializer.service.ts b/src/app/core/demo-data/demo-data-initializer.service.ts index 6f480b07ef..e4d5d3a78c 100644 --- a/src/app/core/demo-data/demo-data-initializer.service.ts +++ b/src/app/core/demo-data/demo-data-initializer.service.ts @@ -1,6 +1,5 @@ import { Injectable } from "@angular/core"; import { DemoDataService } from "./demo-data.service"; -import { SessionService } from "../session/session-service/session.service"; import { DemoUserGeneratorService } from "../user/demo-user-generator.service"; import { LocalSession } from "../session/session-service/local-session"; import { MatDialog } from "@angular/material/dialog"; @@ -28,7 +27,7 @@ export class DemoDataInitializerService { constructor( private demoDataService: DemoDataService, - private sessionService: SessionService, + private localSession: LocalSession, private dialog: MatDialog, private loggingService: LoggingService, private database: Database @@ -54,7 +53,7 @@ export class DemoDataInitializerService { dialogRef.close(); - await this.sessionService.login( + await this.localSession.login( DemoUserGeneratorService.DEFAULT_USERNAME, DemoUserGeneratorService.DEFAULT_PASSWORD ); @@ -62,10 +61,10 @@ export class DemoDataInitializerService { } private syncDatabaseOnUserChange() { - this.sessionService.loginState.subscribe((state) => { + this.localSession.loginState.subscribe((state) => { if ( state === LoginState.LOGGED_IN && - this.sessionService.getCurrentUser().name !== + this.localSession.getCurrentUser().name !== DemoUserGeneratorService.DEFAULT_USERNAME ) { // There is a slight race-condition with session type local @@ -79,15 +78,14 @@ export class DemoDataInitializerService { } private registerDemoUsers() { - const localSession = this.sessionService as LocalSession; - localSession.saveUser( + this.localSession.saveUser( { name: DemoUserGeneratorService.DEFAULT_USERNAME, roles: ["user_app"], }, DemoUserGeneratorService.DEFAULT_PASSWORD ); - localSession.saveUser( + this.localSession.saveUser( { name: DemoUserGeneratorService.ADMIN_USERNAME, roles: ["user_app", "admin_app"], diff --git a/src/app/core/entity-components/entity-list/filter-generator.service.spec.ts b/src/app/core/entity-components/entity-list/filter-generator.service.spec.ts index a91af528b3..7b97f6b2e6 100644 --- a/src/app/core/entity-components/entity-list/filter-generator.service.spec.ts +++ b/src/app/core/entity-components/entity-list/filter-generator.service.spec.ts @@ -1,9 +1,7 @@ import { TestBed } from "@angular/core/testing"; import { FilterGeneratorService } from "./filter-generator.service"; -import { ConfigService } from "../../config/config.service"; import { EntityMapperService } from "../../entity/entity-mapper.service"; -import { LoggingService } from "../../logging/logging.service"; import { BooleanFilterConfig, PrebuiltFilterConfig } from "./EntityListConfig"; import { School } from "../../../child-dev-project/schools/model/school"; import { Note } from "../../../child-dev-project/notes/model/note"; @@ -12,36 +10,17 @@ import { ChildSchoolRelation } from "../../../child-dev-project/children/model/c import { Child } from "../../../child-dev-project/children/model/child"; import moment from "moment"; import { EntityConfigService } from "app/core/entity/entity-config.service"; -import { EntitySchemaService } from "../../entity/schema/entity-schema.service"; -import { - EntityRegistry, - entityRegistry, -} from "../../entity/database-entity.decorator"; +import { MockedTestingModule } from "../../../utils/mocked-testing.module"; describe("FilterGeneratorService", () => { let service: FilterGeneratorService; - let mockEntityMapper: jasmine.SpyObj; beforeEach(async () => { - mockEntityMapper = jasmine.createSpyObj(["loadType", "load"]); - mockEntityMapper.load.and.rejectWith(); TestBed.configureTestingModule({ - providers: [ - ConfigService, - EntitySchemaService, - { provide: EntityMapperService, useValue: mockEntityMapper }, - LoggingService, - EntityConfigService, - { - provide: EntityRegistry, - useValue: entityRegistry, - }, - ], + imports: [MockedTestingModule.withState()], }); service = TestBed.inject(FilterGeneratorService); - const configService = TestBed.inject(ConfigService); const entityConfigService = TestBed.inject(EntityConfigService); - await configService.loadConfig(); entityConfigService.addConfigAttributes(School); entityConfigService.addConfigAttributes(Child); }); @@ -75,11 +54,6 @@ describe("FilterGeneratorService", () => { }); it("should create a configurable enum filter", async () => { - const getConfigSpy = spyOn( - TestBed.inject(ConfigService), - "getConfigurableEnumValues" - ); - getConfigSpy.and.returnValue(defaultInteractionTypes); const interactionTypes = defaultInteractionTypes.map((it) => { return { key: it.id, label: it.label }; }); @@ -103,7 +77,7 @@ describe("FilterGeneratorService", () => { school1.name = "First School"; const school2 = new School(); school2.name = "Second School"; - mockEntityMapper.loadType.and.resolveTo([school1, school2]); + await TestBed.inject(EntityMapperService).saveAll([school1, school2]); const csr1 = new ChildSchoolRelation(); csr1.schoolId = school1.getId(); const csr2 = new ChildSchoolRelation(); diff --git a/src/app/core/entity-components/entity-utils/dynamic-form-components/edit-entity-array/edit-entity-array.component.spec.ts b/src/app/core/entity-components/entity-utils/dynamic-form-components/edit-entity-array/edit-entity-array.component.spec.ts index e9b8369cce..f29d0e1ae3 100644 --- a/src/app/core/entity-components/entity-utils/dynamic-form-components/edit-entity-array/edit-entity-array.component.spec.ts +++ b/src/app/core/entity-components/entity-utils/dynamic-form-components/edit-entity-array/edit-entity-array.component.spec.ts @@ -1,33 +1,18 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { EditEntityArrayComponent } from "./edit-entity-array.component"; -import { EntityMapperService } from "../../../../entity/entity-mapper.service"; import { Child } from "../../../../../child-dev-project/children/model/child"; -import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { EntityUtilsModule } from "../../entity-utils.module"; -import { EntitySchemaService } from "../../../../entity/schema/entity-schema.service"; import { setupEditComponent } from "../edit-component.spec"; -import { - EntityRegistry, - entityRegistry, -} from "../../../../entity/database-entity.decorator"; +import { MockedTestingModule } from "../../../../../utils/mocked-testing.module"; describe("EditEntityArrayComponent", () => { let component: EditEntityArrayComponent; let fixture: ComponentFixture; - let mockEntityMapper: jasmine.SpyObj; beforeEach(async () => { - mockEntityMapper = jasmine.createSpyObj(["loadType"]); - mockEntityMapper.loadType.and.resolveTo([]); await TestBed.configureTestingModule({ - imports: [EntityUtilsModule, NoopAnimationsModule], - declarations: [EditEntityArrayComponent], - providers: [ - { provide: EntityMapperService, useValue: mockEntityMapper }, - { provide: EntityRegistry, useValue: entityRegistry }, - EntitySchemaService, - ], + imports: [EntityUtilsModule, MockedTestingModule.withState()], }).compileComponents(); }); diff --git a/src/app/core/entity-components/entity-utils/dynamic-form-components/edit-single-entity/edit-single-entity.component.spec.ts b/src/app/core/entity-components/entity-utils/dynamic-form-components/edit-single-entity/edit-single-entity.component.spec.ts index 69f7905782..c95a8dd280 100644 --- a/src/app/core/entity-components/entity-utils/dynamic-form-components/edit-single-entity/edit-single-entity.component.spec.ts +++ b/src/app/core/entity-components/entity-utils/dynamic-form-components/edit-single-entity/edit-single-entity.component.spec.ts @@ -14,26 +14,18 @@ import { School } from "../../../../../child-dev-project/schools/model/school"; import { EntityUtilsModule } from "../../entity-utils.module"; import { Child } from "../../../../../child-dev-project/children/model/child"; import { TypedFormControl } from "../edit-component"; -import { FontAwesomeTestingModule } from "@fortawesome/angular-fontawesome/testing"; import { MockedTestingModule } from "../../../../../utils/mocked-testing.module"; describe("EditSingleEntityComponent", () => { let component: EditSingleEntityComponent; let fixture: ComponentFixture; - let mockEntityMapper: jasmine.SpyObj; + let loadTypeSpy: jasmine.Spy; beforeEach(async () => { - mockEntityMapper = jasmine.createSpyObj(["loadType"]); - mockEntityMapper.loadType.and.resolveTo([]); - await TestBed.configureTestingModule({ - imports: [ - EntityUtilsModule, - FontAwesomeTestingModule, - MockedTestingModule, - ], - providers: [{ provide: EntityMapperService, useValue: mockEntityMapper }], + imports: [EntityUtilsModule, MockedTestingModule.withState()], }).compileComponents(); + loadTypeSpy = spyOn(TestBed.inject(EntityMapperService), "loadType"); }); beforeEach(() => { @@ -54,12 +46,12 @@ describe("EditSingleEntityComponent", () => { it("should show all entities of the given type", fakeAsync(() => { const school1 = School.create({ name: "First School" }); const school2 = School.create({ name: "Second School " }); - mockEntityMapper.loadType.and.resolveTo([school1, school2]); + loadTypeSpy.and.resolveTo([school1, school2]); initComponent(); tick(); - expect(mockEntityMapper.loadType).toHaveBeenCalled(); + expect(loadTypeSpy).toHaveBeenCalled(); expect(component.entities).toEqual([school1, school2]); component.updateAutocomplete(""); expect(component.autocompleteEntities.value).toEqual([school1, school2]); @@ -87,7 +79,7 @@ describe("EditSingleEntityComponent", () => { const child1 = Child.create("First Child"); const child2 = Child.create("Second Child"); component.formControl.setValue(child1.getId()); - mockEntityMapper.loadType.and.resolveTo([child1, child2]); + loadTypeSpy.and.resolveTo([child1, child2]); initComponent(); tick(); diff --git a/src/app/core/entity/database-indexing/database-indexing.service.spec.ts b/src/app/core/entity/database-indexing/database-indexing.service.spec.ts index bb6249ec7c..53c38288ed 100644 --- a/src/app/core/entity/database-indexing/database-indexing.service.spec.ts +++ b/src/app/core/entity/database-indexing/database-indexing.service.spec.ts @@ -19,31 +19,16 @@ import { DatabaseIndexingService } from "./database-indexing.service"; import { Database } from "../../database/database"; import { EntitySchemaService } from "../schema/entity-schema.service"; import { expectObservable } from "../../../utils/test-utils/observable-utils"; -import { fakeAsync, flush, tick } from "@angular/core/testing"; -import { SessionService } from "../../session/session-service/session.service"; -import { BehaviorSubject } from "rxjs"; -import { LoginState } from "../../session/session-states/login-state.enum"; +import { fakeAsync, tick } from "@angular/core/testing"; import { take } from "rxjs/operators"; describe("DatabaseIndexingService", () => { let service: DatabaseIndexingService; let mockDb: jasmine.SpyObj; - let mockSession: jasmine.SpyObj; - let loginState: BehaviorSubject; beforeEach(() => { mockDb = jasmine.createSpyObj("mockDb", ["saveDatabaseIndex", "query"]); - loginState = new BehaviorSubject(LoginState.LOGGED_OUT); - mockSession = jasmine.createSpyObj([], { loginState }); - service = new DatabaseIndexingService( - mockDb, - new EntitySchemaService(), - mockSession - ); - }); - - afterEach(() => { - loginState.complete(); + service = new DatabaseIndexingService(mockDb, new EntitySchemaService()); }); it("should pass through any query to the database", async () => { @@ -98,7 +83,6 @@ describe("DatabaseIndexingService", () => { title: "Preparing data (Indexing)", details: testIndexName, pending: true, - designDoc: testDesignDoc, }, ]); @@ -109,7 +93,6 @@ describe("DatabaseIndexingService", () => { title: "Preparing data (Indexing)", details: testIndexName, pending: false, - designDoc: testDesignDoc, }, ]); @@ -134,7 +117,6 @@ describe("DatabaseIndexingService", () => { title: "Preparing data (Indexing)", details: testIndexName, pending: true, - designDoc: testDesignDoc, }, ]); @@ -146,32 +128,10 @@ describe("DatabaseIndexingService", () => { details: testIndexName, pending: false, error: testErr, - designDoc: testDesignDoc, }, ]); }); - it("should re-create indices whenever a new user logs in", fakeAsync(() => { - const testDesignDoc = { - _id: "_design/test-index", - views: {}, - }; - - service.createIndex(testDesignDoc); - tick(); - - expect(mockDb.saveDatabaseIndex.calls.allArgs()).toEqual([[testDesignDoc]]); - - loginState.next(LoginState.LOGGED_IN); - - tick(); - expect(mockDb.saveDatabaseIndex.calls.allArgs()).toEqual([ - [testDesignDoc], - [testDesignDoc], - ]); - flush(); - })); - it("should only register indices once", async () => { const testDesignDoc = { _id: "_design/test-index", diff --git a/src/app/core/entity/database-indexing/database-indexing.service.ts b/src/app/core/entity/database-indexing/database-indexing.service.ts index 6b666517b3..aa74891f05 100644 --- a/src/app/core/entity/database-indexing/database-indexing.service.ts +++ b/src/app/core/entity/database-indexing/database-indexing.service.ts @@ -21,13 +21,7 @@ import { BehaviorSubject, Observable } from "rxjs"; import { BackgroundProcessState } from "../../sync-status/background-process-state.interface"; import { Entity, EntityConstructor } from "../model/entity"; import { EntitySchemaService } from "../schema/entity-schema.service"; -import { filter, first } from "rxjs/operators"; -import { SessionService } from "../../session/session-service/session.service"; -import { LoginState } from "../../session/session-states/login-state.enum"; - -interface IndexState extends BackgroundProcessState { - designDoc: any; -} +import { first } from "rxjs/operators"; /** * Manage database query index creation and use, working as a facade in front of the Database service. @@ -37,26 +31,19 @@ interface IndexState extends BackgroundProcessState { providedIn: "root", }) export class DatabaseIndexingService { - private _indicesRegistered = new BehaviorSubject([]); + private _indicesRegistered = new BehaviorSubject( + [] + ); /** All currently registered indices with their status */ - get indicesRegistered(): Observable { + get indicesRegistered(): Observable { return this._indicesRegistered.asObservable(); } constructor( private db: Database, - private entitySchemaService: EntitySchemaService, - private sessionService: SessionService - ) { - sessionService.loginState - .pipe(filter((state) => state === LoginState.LOGGED_IN)) - .subscribe(() => - this._indicesRegistered.value.forEach(({ designDoc }) => - this.createIndex(designDoc) - ) - ); - } + private entitySchemaService: EntitySchemaService + ) {} /** * Register a new database query to be created/updated and indexed. @@ -67,11 +54,10 @@ export class DatabaseIndexingService { */ async createIndex(designDoc: any): Promise { const indexDetails = designDoc._id.replace(/_design\//, ""); - const indexState: IndexState = { + const indexState: BackgroundProcessState = { title: $localize`Preparing data (Indexing)`, details: indexDetails, pending: true, - designDoc, }; const indexCreationPromise = this.db.saveDatabaseIndex(designDoc); diff --git a/src/app/core/entity/mock-entity-mapper-service.ts b/src/app/core/entity/mock-entity-mapper-service.ts index bbfa444f35..3ffc534fad 100644 --- a/src/app/core/entity/mock-entity-mapper-service.ts +++ b/src/app/core/entity/mock-entity-mapper-service.ts @@ -114,7 +114,10 @@ export class MockEntityMapperService extends EntityMapperService { forceUpdate: boolean = false ): Promise { this.add(entity); - return Promise.resolve(); + } + + async saveAll(entities: Entity[]): Promise { + this.addAll(entities); } remove(entity: T): Promise { diff --git a/src/app/core/entity/model/entity.spec.ts b/src/app/core/entity/model/entity.spec.ts index a74296ef63..d06c9e2435 100644 --- a/src/app/core/entity/model/entity.spec.ts +++ b/src/app/core/entity/model/entity.spec.ts @@ -19,9 +19,7 @@ import { Entity, EntityConstructor } from "./entity"; import { EntitySchemaService } from "../schema/entity-schema.service"; import { DatabaseField } from "../database-field.decorator"; import { ConfigurableEnumDatatype } from "../../configurable-enum/configurable-enum-datatype/configurable-enum-datatype"; -import { ConfigService } from "../../config/config.service"; -import { LoggingService } from "../../logging/logging.service"; -import { mockEntityMapper } from "../mock-entity-mapper-service"; +import { createTestingConfigService } from "../../config/config.service"; describe("Entity", () => { let entitySchemaService: EntitySchemaService; @@ -107,11 +105,7 @@ export function testEntitySubclass( it("should only load and store properties defined in the schema", () => { const schemaService = new EntitySchemaService(); - const configService = new ConfigService( - mockEntityMapper(), - new LoggingService() - ); - configService.loadConfig(); + const configService = createTestingConfigService(); schemaService.registerSchemaDatatype( new ConfigurableEnumDatatype(configService) ); diff --git a/src/app/core/navigation/navigation/navigation.component.spec.ts b/src/app/core/navigation/navigation/navigation.component.spec.ts index bc6f90d2ec..0a3750d2ff 100644 --- a/src/app/core/navigation/navigation/navigation.component.spec.ts +++ b/src/app/core/navigation/navigation/navigation.component.spec.ts @@ -43,9 +43,10 @@ describe("NavigationComponent", () => { beforeEach( waitForAsync(() => { mockConfigUpdated = new BehaviorSubject(null); - mockConfigService = jasmine.createSpyObj(["getConfig"]); + mockConfigService = jasmine.createSpyObj(["getConfig"], { + configUpdates: mockConfigUpdated, + }); mockConfigService.getConfig.and.returnValue({ items: [] }); - mockConfigService.configUpdates = mockConfigUpdated; mockUserRoleGuard = jasmine.createSpyObj(["canActivate"]); mockUserRoleGuard.canActivate.and.returnValue(true); diff --git a/src/app/core/permissions/ability/ability.service.spec.ts b/src/app/core/permissions/ability/ability.service.spec.ts index b0a5330dba..b39132ee93 100644 --- a/src/app/core/permissions/ability/ability.service.spec.ts +++ b/src/app/core/permissions/ability/ability.service.spec.ts @@ -6,9 +6,6 @@ import { SessionService } from "../../session/session-service/session.service"; import { Child } from "../../../child-dev-project/children/model/child"; import { Note } from "../../../child-dev-project/notes/model/note"; import { EntityMapperService } from "../../entity/entity-mapper.service"; -import { EntitySchemaService } from "../../entity/schema/entity-schema.service"; -import { SyncState } from "../../session/session-states/sync-state.enum"; -import { LoginState } from "../../session/session-states/login-state.enum"; import { PermissionEnforcerService } from "../permission-enforcer/permission-enforcer.service"; import { DatabaseUser } from "../../session/session-service/local-user"; import { User } from "../../user/user"; @@ -18,14 +15,16 @@ import { ConfigurableEnumModule } from "../../configurable-enum/configurable-enu import { DatabaseRule, DatabaseRules } from "../permission-types"; import { Config } from "../../config/config"; import { LoggingService } from "../../logging/logging.service"; +import { MockedTestingModule } from "../../../utils/mocked-testing.module"; +import { UpdatedEntity } from "../../entity/model/entity-update"; +import { PermissionsModule } from "../permissions.module"; describe("AbilityService", () => { let service: AbilityService; let mockSessionService: jasmine.SpyObj; let ability: EntityAbility; - let mockSyncState: Subject; - let mockLoginState: Subject; let mockEntityMapper: jasmine.SpyObj; + let entityUpdates: Subject>>; let mockPermissionEnforcer: jasmine.SpyObj; let mockLoggingService: jasmine.SpyObj; const user: DatabaseUser = { name: "testUser", roles: ["user_app"] }; @@ -38,16 +37,11 @@ describe("AbilityService", () => { }; beforeEach(() => { - mockEntityMapper = jasmine.createSpyObj(["load"]); - mockEntityMapper.load.and.resolveTo( - new Config(Config.PERMISSION_KEY, rules) - ); - mockSyncState = new Subject(); - mockLoginState = new Subject(); - mockSessionService = jasmine.createSpyObj(["getCurrentUser"], { - syncState: mockSyncState, - loginState: mockLoginState, - }); + mockEntityMapper = jasmine.createSpyObj(["load", "receiveUpdates"]); + mockEntityMapper.load.and.rejectWith(); + entityUpdates = new Subject(); + mockEntityMapper.receiveUpdates.and.returnValue(entityUpdates); + mockSessionService = jasmine.createSpyObj(["getCurrentUser"]); mockSessionService.getCurrentUser.and.returnValue(user); mockPermissionEnforcer = jasmine.createSpyObj([ "enforcePermissionsOnLocalData", @@ -55,9 +49,12 @@ describe("AbilityService", () => { mockLoggingService = jasmine.createSpyObj(["warn"]); TestBed.configureTestingModule({ - imports: [ConfigurableEnumModule], + imports: [ + PermissionsModule, + ConfigurableEnumModule, + MockedTestingModule.withState(), + ], providers: [ - EntityAbility, { provide: SessionService, useValue: mockSessionService }, { provide: EntityMapperService, useValue: mockEntityMapper }, { @@ -65,8 +62,6 @@ describe("AbilityService", () => { useValue: mockPermissionEnforcer, }, { provide: LoggingService, useValue: mockLoggingService }, - EntitySchemaService, - AbilityService, ], }); service = TestBed.inject(AbilityService); @@ -74,8 +69,7 @@ describe("AbilityService", () => { }); afterEach(() => { - mockLoginState.complete(); - mockSyncState.complete(); + entityUpdates.complete(); }); it("should be created", () => { @@ -83,60 +77,59 @@ describe("AbilityService", () => { }); it("should fetch the rules object from the database", () => { - mockLoginState.next(LoginState.LOGGED_IN); - expect(mockEntityMapper.load).toHaveBeenCalledWith( Config, Config.PERMISSION_KEY ); }); - it("should retry fetching the rules after the sync has completed", fakeAsync(() => { - mockEntityMapper.load.and.returnValues( - Promise.reject("first error"), - Promise.resolve(new Config(Config.PERMISSION_KEY, rules)) - ); - - mockLoginState.next(LoginState.LOGGED_IN); + it("should update the rules when a change is published", () => { + mockEntityMapper.load.and.rejectWith("no initial config"); - expect(mockEntityMapper.load).toHaveBeenCalledTimes(1); + expect(mockEntityMapper.load).toHaveBeenCalled(); // Default rule expect(ability.rules).toEqual([{ action: "manage", subject: "all" }]); - mockSyncState.next(SyncState.COMPLETED); + entityUpdates.next({ + entity: new Config(Config.PERMISSION_KEY, rules), + type: "update", + }); - expect(mockEntityMapper.load).toHaveBeenCalledTimes(2); - tick(); expect(ability.rules).toEqual(rules[user.roles[0]]); - })); + }); - it("should update the ability with the received rules for the logged in user", fakeAsync(() => { + it("should update the ability with the received rules for the logged in user", () => { spyOn(ability, "update"); - - mockLoginState.next(LoginState.LOGGED_IN); - tick(); + entityUpdates.next({ + entity: new Config(Config.PERMISSION_KEY, rules), + type: "update", + }); expect(ability.update).toHaveBeenCalledWith(rules.user_app); - })); + }); - it("should update the ability with rules for all roles the logged in user has", fakeAsync(() => { + it("should update the ability with rules for all roles the logged in user has", () => { spyOn(ability, "update"); mockSessionService.getCurrentUser.and.returnValue({ name: "testAdmin", roles: ["user_app", "admin_app"], }); - mockLoginState.next(LoginState.LOGGED_IN); - tick(); + entityUpdates.next({ + entity: new Config(Config.PERMISSION_KEY, rules), + type: "update", + }); expect(ability.update).toHaveBeenCalledWith( rules.user_app.concat(rules.admin_app) ); - })); + }); - it("should create an ability that correctly uses the defined rules", fakeAsync(() => { - mockLoginState.next(LoginState.LOGGED_IN); - tick(); + it("should create an ability that correctly uses the defined rules", () => { + entityUpdates.next({ + entity: new Config(Config.PERMISSION_KEY, rules), + type: "update", + }); expect(ability.can("read", Child)).toBeTrue(); expect(ability.can("create", Child)).toBeFalse(); @@ -151,38 +144,32 @@ describe("AbilityService", () => { name: "testAdmin", roles: ["user_app", "admin_app"], }); - mockLoginState.next(LoginState.LOGGED_IN); - tick(); + + const updatedConfig = new Config(Config.PERMISSION_KEY, rules); + updatedConfig._rev = "update"; + entityUpdates.next({ entity: updatedConfig, type: "update" }); expect(ability.can("manage", Child)).toBeTrue(); expect(ability.can("manage", new Child())).toBeTrue(); expect(ability.can("manage", Note)).toBeTrue(); expect(ability.can("manage", new Note())).toBeTrue(); - })); + }); it("should throw an error when checking permissions on a object that is not a Entity", () => { - mockLoginState.next(LoginState.LOGGED_IN); + entityUpdates.next({ + entity: new Config(Config.PERMISSION_KEY, rules), + type: "update", + }); class TestClass {} expect(() => ability.can("read", new TestClass() as any)).toThrowError(); }); it("should give all permissions if no rules object can be fetched", () => { - mockEntityMapper.load.and.rejectWith(); - - mockLoginState.next(LoginState.LOGGED_IN); - // Request failed, sync not started - offline without cached rules object expect(ability.can("read", Child)).toBeTrue(); expect(ability.can("update", Child)).toBeTrue(); expect(ability.can("manage", new Note())).toBeTrue(); - - mockSyncState.next(SyncState.STARTED); - - // Request failed, sync started - no rules object exists - expect(ability.can("read", Child)).toBeTrue(); - expect(ability.can("update", Child)).toBeTrue(); - expect(ability.can("manage", new Note())).toBeTrue(); }); it("should notify when the rules are updated", (done) => { @@ -192,99 +179,100 @@ describe("AbilityService", () => { done(); }); - mockLoginState.next(LoginState.LOGGED_IN); + entityUpdates.next({ + entity: new Config(Config.PERMISSION_KEY, rules), + type: "update", + }); }); - it("should call the ability enforcer after updating the rules", fakeAsync(() => { - mockLoginState.next(LoginState.LOGGED_IN); - tick(); + it("should call the ability enforcer after updating the rules", () => { + entityUpdates.next({ + entity: new Config(Config.PERMISSION_KEY, rules), + type: "update", + }); expect( mockPermissionEnforcer.enforcePermissionsOnLocalData ).toHaveBeenCalled(); - })); + }); - it("should allow to access user properties in the rules", fakeAsync(() => { - mockEntityMapper.load.and.resolveTo( - new Config(Config.PERMISSION_KEY, { - user_app: [ - { - subject: "User", - action: "manage", - conditions: { name: "${user.name}" }, - }, - ], - }) - ); - mockLoginState.next(LoginState.LOGGED_IN); - tick(); + it("should allow to access user properties in the rules", () => { + const config = new Config(Config.PERMISSION_KEY, { + user_app: [ + { + subject: "User", + action: "manage", + conditions: { name: "${user.name}" }, + }, + ], + }); + entityUpdates.next({ entity: config, type: "update" }); const userEntity = new User(); userEntity.name = user.name; expect(ability.can("manage", userEntity)).toBeTrue(); userEntity.name = "another user"; expect(ability.cannot("manage", userEntity)).toBeTrue(); - })); + }); it("should allow to check conditions with complex data types", fakeAsync(() => { const classInteraction = defaultInteractionTypes.find( (type) => type.id === "SCHOOL_CLASS" ); - mockEntityMapper.load.and.resolveTo( - new Config(Config.PERMISSION_KEY, { - user_app: [ - { - subject: "Note", - action: "read", - conditions: { category: classInteraction.id }, - }, - ], - }) - ); - mockLoginState.next(LoginState.LOGGED_IN); - tick(); + const config = new Config(Config.PERMISSION_KEY, { + user_app: [ + { + subject: "Note", + action: "read", + conditions: { category: classInteraction.id }, + }, + ], + }); + entityUpdates.next({ entity: config, type: "update" }); const note = new Note(); expect(ability.can("read", note)).toBeFalse(); note.category = classInteraction; + tick(); expect(ability.can("read", note)).toBeTrue(); })); - it("should log a warning if no rules are found for a user", fakeAsync(() => { + it("should log a warning if no rules are found for a user", () => { mockSessionService.getCurrentUser.and.returnValue({ name: "new-user", roles: ["invalid_role"], }); - mockLoginState.next(LoginState.LOGGED_IN); - tick(); + entityUpdates.next({ + entity: new Config(Config.PERMISSION_KEY, rules), + type: "update", + }); expect(mockLoggingService.warn).toHaveBeenCalled(); - })); + }); - it("should prepend default rules to all users", fakeAsync(() => { + it("should prepend default rules to all users", () => { const defaultRules: DatabaseRule[] = [ { subject: "Config", action: "read" }, { subject: "ProgressDashboardConfig", action: "manage" }, ]; - mockEntityMapper.load.and.resolveTo( - new Config( - Config.PERMISSION_KEY, - Object.assign({ default: defaultRules } as DatabaseRules, rules) - ) + const config = new Config( + Config.PERMISSION_KEY, + Object.assign({ default: defaultRules } as DatabaseRules, rules) ); - mockLoginState.next(LoginState.LOGGED_IN); - tick(); + entityUpdates.next({ entity: config, type: "update" }); + expect(ability.rules).toEqual(defaultRules.concat(...rules.user_app)); mockSessionService.getCurrentUser.and.returnValue({ name: "admin", roles: ["user_app", "admin_app"], }); - mockLoginState.next(LoginState.LOGGED_IN); - tick(); + + config._rev = "update"; + entityUpdates.next({ entity: config, type: "update" }); expect(ability.rules).toEqual( defaultRules.concat(...rules.user_app, ...rules.admin_app) ); - })); + }); }); diff --git a/src/app/core/permissions/ability/ability.service.ts b/src/app/core/permissions/ability/ability.service.ts index 541818882a..ca3d59c00b 100644 --- a/src/app/core/permissions/ability/ability.service.ts +++ b/src/app/core/permissions/ability/ability.service.ts @@ -2,10 +2,8 @@ import { Injectable } from "@angular/core"; import { Entity, EntityConstructor } from "../../entity/model/entity"; import { SessionService } from "../../session/session-service/session.service"; import { filter } from "rxjs/operators"; -import { SyncState } from "../../session/session-states/sync-state.enum"; -import { merge, Observable, Subject } from "rxjs"; +import { Observable, Subject } from "rxjs"; import { DatabaseRule, DatabaseRules } from "../permission-types"; -import { LoginState } from "../../session/session-states/login-state.enum"; import { EntityMapperService } from "../../entity/entity-mapper.service"; import { PermissionEnforcerService } from "../permission-enforcer/permission-enforcer.service"; import { DatabaseUser } from "../../session/session-service/local-user"; @@ -45,14 +43,12 @@ export class AbilityService { private permissionEnforcer: PermissionEnforcerService, private logger: LoggingService ) { - merge( - this.sessionService.loginState.pipe( - filter((state) => state === LoginState.LOGGED_IN) - ), - this.sessionService.syncState.pipe( - filter((state) => state === SyncState.COMPLETED) - ) - ).subscribe(() => this.initRules()); + // TODO this setup is very similar to `ConfigService` + this.initRules(); + this.entityMapper + .receiveUpdates>(Config) + .pipe(filter(({ entity }) => entity.getId() === Config.PERMISSION_KEY)) + .subscribe(({ entity }) => this.updateAbilityWithUserRules(entity.data)); } private initRules(): Promise { @@ -60,11 +56,11 @@ export class AbilityService { this.ability.update([{ action: "manage", subject: "all" }]); return this.entityMapper .load>(Config, Config.PERMISSION_KEY) - .then((permissions) => this.updateAbilityWithUserRules(permissions.data)) + .then((config) => this.updateAbilityWithUserRules(config.data)) .catch(() => undefined); } - private async updateAbilityWithUserRules(rules: DatabaseRules) { + private updateAbilityWithUserRules(rules: DatabaseRules): Promise { const userRules = this.getRulesForUser(rules); if (userRules.length === 0 || userRules.length === rules.default?.length) { // No rules or only default rules defined @@ -74,7 +70,7 @@ export class AbilityService { ); } this.updateAbilityWithRules(userRules); - await this.permissionEnforcer.enforcePermissionsOnLocalData(userRules); + return this.permissionEnforcer.enforcePermissionsOnLocalData(userRules); } private getRulesForUser(rules: DatabaseRules): DatabaseRule[] { diff --git a/src/app/core/permissions/permission-enforcer/permission-enforcer.service.spec.ts b/src/app/core/permissions/permission-enforcer/permission-enforcer.service.spec.ts index 11bc6e0f9b..e473d810ee 100644 --- a/src/app/core/permissions/permission-enforcer/permission-enforcer.service.spec.ts +++ b/src/app/core/permissions/permission-enforcer/permission-enforcer.service.spec.ts @@ -1,7 +1,7 @@ import { fakeAsync, TestBed, tick } from "@angular/core/testing"; import { PermissionEnforcerService } from "./permission-enforcer.service"; -import { DatabaseRule } from "../permission-types"; +import { DatabaseRule, DatabaseRules } from "../permission-types"; import { SessionService } from "../../session/session-service/session.service"; import { TEST_USER } from "../../../utils/mocked-testing.module"; import { EntityMapperService } from "../../entity/entity-mapper.service"; @@ -14,14 +14,14 @@ import { mockEntityMapper } from "../../entity/mock-entity-mapper-service"; import { LOCATION_TOKEN } from "../../../utils/di-tokens"; import { AnalyticsService } from "../../analytics/analytics.service"; import { EntitySchemaService } from "../../entity/schema/entity-schema.service"; -import { BehaviorSubject, Subject } from "rxjs"; -import { LoginState } from "../../session/session-states/login-state.enum"; +import { Subject } from "rxjs"; import { EntityAbility } from "../ability/entity-ability"; import { Config } from "../../config/config"; import { entityRegistry, EntityRegistry, } from "../../entity/database-entity.decorator"; +import { UpdatedEntity } from "../../entity/model/entity-update"; describe("PermissionEnforcerService", () => { let service: PermissionEnforcerService; @@ -33,15 +33,11 @@ describe("PermissionEnforcerService", () => { let mockDatabase: jasmine.SpyObj; let mockLocation: jasmine.SpyObj; let mockAnalytics: jasmine.SpyObj; - const mockLoginState = new BehaviorSubject(LoginState.LOGGED_IN); - let loadSpy: jasmine.Spy; let entityMapper: EntityMapperService; + const entityUpdates = new Subject>>(); beforeEach(fakeAsync(() => { - mockSession = jasmine.createSpyObj(["getCurrentUser"], { - loginState: mockLoginState, - syncState: new Subject(), - }); + mockSession = jasmine.createSpyObj(["getCurrentUser"]); mockSession.getCurrentUser.and.returnValue({ name: TEST_USER, roles: ["user_app"], @@ -64,10 +60,10 @@ describe("PermissionEnforcerService", () => { AbilityService, ], }); + entityMapper = TestBed.inject(EntityMapperService); + spyOn(entityMapper, "receiveUpdates").and.returnValue(entityUpdates); service = TestBed.inject(PermissionEnforcerService); TestBed.inject(AbilityService); - entityMapper = TestBed.inject(EntityMapperService); - loadSpy = spyOn(entityMapper, "load"); })); afterEach(async () => { @@ -213,7 +209,7 @@ describe("PermissionEnforcerService", () => { async function updateRulesAndTriggerEnforcer(rules: DatabaseRule[]) { const role = mockSession.getCurrentUser().roles[0]; - loadSpy.and.resolveTo(new Config(Config.PERMISSION_KEY, { [role]: rules })); - mockLoginState.next(LoginState.LOGGED_IN); + const config = new Config(Config.PERMISSION_KEY, { [role]: rules }); + entityUpdates.next({ entity: config, type: "update" }); } }); diff --git a/src/app/core/permissions/permission-guard/user-role.guard.spec.ts b/src/app/core/permissions/permission-guard/user-role.guard.spec.ts index bc4194dc78..c2f62f84aa 100644 --- a/src/app/core/permissions/permission-guard/user-role.guard.spec.ts +++ b/src/app/core/permissions/permission-guard/user-role.guard.spec.ts @@ -3,6 +3,8 @@ import { TestBed } from "@angular/core/testing"; import { UserRoleGuard } from "./user-role.guard"; import { SessionService } from "../../session/session-service/session.service"; import { DatabaseUser } from "../../session/session-service/local-user"; +import { RouterTestingModule } from "@angular/router/testing"; +import { ActivatedRouteSnapshot, Router } from "@angular/router"; describe("UserRoleGuard", () => { let guard: UserRoleGuard; @@ -16,6 +18,7 @@ describe("UserRoleGuard", () => { beforeEach(() => { mockSessionService = jasmine.createSpyObj(["getCurrentUser"]); TestBed.configureTestingModule({ + imports: [RouterTestingModule], providers: [ { provide: SessionService, useValue: mockSessionService }, UserRoleGuard, @@ -41,6 +44,8 @@ describe("UserRoleGuard", () => { it("should return false for a user without permissions", () => { mockSessionService.getCurrentUser.and.returnValue(normalUser); + const router = TestBed.inject(Router); + spyOn(router, "navigate"); const result = guard.canActivate({ routeConfig: { path: "url" }, @@ -48,6 +53,22 @@ describe("UserRoleGuard", () => { } as any); expect(result).toBeFalse(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it("should navigate to 404 for real navigation requests without permissions", () => { + mockSessionService.getCurrentUser.and.returnValue(normalUser); + const router = TestBed.inject(Router); + spyOn(router, "navigate"); + const route = new ActivatedRouteSnapshot(); + Object.assign(route, { + routeConfig: { path: "url" }, + data: { permittedUserRoles: ["admin"] }, + }); + + guard.canActivate(route); + + expect(router.navigate).toHaveBeenCalledWith(["/404"]); }); it("should return true if no config is set", () => { diff --git a/src/app/core/permissions/permission-guard/user-role.guard.ts b/src/app/core/permissions/permission-guard/user-role.guard.ts index 8677c59206..4ecd133d31 100644 --- a/src/app/core/permissions/permission-guard/user-role.guard.ts +++ b/src/app/core/permissions/permission-guard/user-role.guard.ts @@ -1,23 +1,34 @@ import { Injectable } from "@angular/core"; -import { ActivatedRouteSnapshot, CanActivate } from "@angular/router"; +import { ActivatedRouteSnapshot, CanActivate, Router } from "@angular/router"; import { SessionService } from "../../session/session-service/session.service"; import { RouteData } from "../../view/dynamic-routing/view-config.interface"; +import { DatabaseUser } from "../../session/session-service/local-user"; /** * A guard that checks the roles of the current user against the permissions which are saved in the route data. */ @Injectable() export class UserRoleGuard implements CanActivate { - constructor(private sessionService: SessionService) {} + constructor(private sessionService: SessionService, private router: Router) {} canActivate(route: ActivatedRouteSnapshot): boolean { const routeData: RouteData = route.data; const user = this.sessionService.getCurrentUser(); - if (routeData?.permittedUserRoles?.length > 0) { + if (this.canAccessRoute(routeData?.permittedUserRoles, user)) { + return true; + } else { + if (route instanceof ActivatedRouteSnapshot) { + // Route should only change if this is a "real" navigation check (not the check in the NavigationComponent) + this.router.navigate(["/404"]); + } + return false; + } + } + + private canAccessRoute(permittedRoles: string[], user: DatabaseUser) { + if (permittedRoles?.length > 0) { // Check if user has a role which is in the list of permitted roles - return routeData.permittedUserRoles.some((role) => - user?.roles.includes(role) - ); + return permittedRoles.some((role) => user?.roles.includes(role)); } else { // No config set => all users are allowed return true; diff --git a/src/app/core/pwa-install/pwa-install.component.spec.ts b/src/app/core/pwa-install/pwa-install.component.spec.ts index 6d358a3958..4cb9bbffc0 100644 --- a/src/app/core/pwa-install/pwa-install.component.spec.ts +++ b/src/app/core/pwa-install/pwa-install.component.spec.ts @@ -53,9 +53,7 @@ describe("PwaInstallComponent", () => { pwaInstallResult.next(); const component = createComponent(); - console.log("created"); tick(); - console.log("checking"); expect(component.showPWAInstallButton).toBeTrue(); mockPWAInstallService.installPWA.and.resolveTo({ outcome: "accepted" }); diff --git a/src/app/core/session/session-service/synced-session.service.spec.ts b/src/app/core/session/session-service/synced-session.service.spec.ts index 868f8a52fe..6a21796a46 100644 --- a/src/app/core/session/session-service/synced-session.service.spec.ts +++ b/src/app/core/session/session-service/synced-session.service.spec.ts @@ -16,24 +16,22 @@ */ import { SyncedSessionService } from "./synced-session.service"; -import { AlertService } from "../../alerts/alert.service"; import { LoginState } from "../session-states/login-state.enum"; import { AppConfig } from "../../app-config/app-config"; import { LocalSession } from "./local-session"; import { RemoteSession } from "./remote-session"; -import { EntitySchemaService } from "../../entity/schema/entity-schema.service"; import { SessionType } from "../session-type"; import { fakeAsync, flush, TestBed, tick } from "@angular/core/testing"; import { HttpClient, HttpErrorResponse } from "@angular/common/http"; -import { LoggingService } from "../../logging/logging.service"; import { of, throwError } from "rxjs"; -import { MatSnackBarModule } from "@angular/material/snack-bar"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { DatabaseUser } from "./local-user"; import { TEST_PASSWORD, TEST_USER } from "../../../utils/mocked-testing.module"; import { testSessionServiceImplementation } from "./session.service.spec"; import { FontAwesomeTestingModule } from "@fortawesome/angular-fontawesome/testing"; import { PouchDatabase } from "../../database/pouch-database"; +import { SessionModule } from "../session.module"; +import { LOCATION_TOKEN } from "../../../utils/di-tokens"; describe("SyncedSessionService", () => { let sessionService: SyncedSessionService; @@ -49,24 +47,19 @@ describe("SyncedSessionService", () => { let syncSpy: jasmine.Spy<() => Promise>; let liveSyncSpy: jasmine.Spy<() => void>; let mockHttpClient: jasmine.SpyObj; + let mockLocation: jasmine.SpyObj; beforeEach(() => { mockHttpClient = jasmine.createSpyObj(["post", "delete", "get"]); mockHttpClient.delete.and.returnValue(of()); mockHttpClient.get.and.returnValue(of()); + mockLocation = jasmine.createSpyObj(["reload"]); TestBed.configureTestingModule({ - imports: [ - MatSnackBarModule, - NoopAnimationsModule, - FontAwesomeTestingModule, - ], + imports: [SessionModule, NoopAnimationsModule, FontAwesomeTestingModule], providers: [ - EntitySchemaService, - AlertService, - LoggingService, - SyncedSessionService, PouchDatabase, { provide: HttpClient, useValue: mockHttpClient }, + { provide: LOCATION_TOKEN, useValue: mockLocation }, ], }); AppConfig.settings = { @@ -80,11 +73,8 @@ describe("SyncedSessionService", () => { }; sessionService = TestBed.inject(SyncedSessionService); - // make private members localSession and remoteSession available in the tests - // @ts-ignore - localSession = sessionService._localSession; - // @ts-ignore - remoteSession = sessionService._remoteSession; + localSession = TestBed.inject(LocalSession); + remoteSession = TestBed.inject(RemoteSession); // Setting up local and remote session to accept TEST_USER and TEST_PASSWORD as valid credentials dbUser = { name: TEST_USER, roles: ["user_app"] }; diff --git a/src/app/core/session/session-service/synced-session.service.ts b/src/app/core/session/session-service/synced-session.service.ts index ffd9ad2128..ba353ae5d9 100644 --- a/src/app/core/session/session-service/synced-session.service.ts +++ b/src/app/core/session/session-service/synced-session.service.ts @@ -15,7 +15,7 @@ * along with ndb-core. If not, see . */ -import { Injectable } from "@angular/core"; +import { Inject, Injectable } from "@angular/core"; import { AlertService } from "../../alerts/alert.service"; import { SessionService } from "./session.service"; @@ -28,10 +28,10 @@ import { LoggingService } from "../../logging/logging.service"; import { HttpClient } from "@angular/common/http"; import { DatabaseUser } from "./local-user"; import { waitForChangeTo } from "../session-states/session-utils"; -import { PouchDatabase } from "../../database/pouch-database"; import { zip } from "rxjs"; import { AppConfig } from "app/core/app-config/app-config"; import { filter } from "rxjs/operators"; +import { LOCATION_TOKEN } from "../../../utils/di-tokens"; /** * A synced session creates and manages a LocalSession and a RemoteSession @@ -46,8 +46,6 @@ export class SyncedSessionService extends SessionService { private readonly LOGIN_RETRY_TIMEOUT = 60000; private readonly POUCHDB_SYNC_BATCH_SIZE = 500; - private readonly _localSession: LocalSession; - private readonly _remoteSession: RemoteSession; private _liveSyncHandle: any; private _liveSyncScheduledHandle: any; private _offlineRetryLoginScheduleHandle: any; @@ -56,11 +54,11 @@ export class SyncedSessionService extends SessionService { private alertService: AlertService, private loggingService: LoggingService, private httpClient: HttpClient, - pouchDatabase: PouchDatabase + private localSession: LocalSession, + private remoteSession: RemoteSession, + @Inject(LOCATION_TOKEN) private location: Location ) { super(); - this._localSession = new LocalSession(pouchDatabase); - this._remoteSession = new RemoteSession(this.httpClient, loggingService); this.syncState .pipe(filter((state) => state === SyncState.COMPLETED)) .subscribe(() => @@ -89,9 +87,10 @@ export class SyncedSessionService extends SessionService { async handleSuccessfulLogin(userObject: DatabaseUser) { this.startSyncAfterLocalAndRemoteLogin(); - await this._remoteSession.handleSuccessfulLogin(userObject); - await this._localSession.handleSuccessfulLogin(userObject); + await this.localSession.handleSuccessfulLogin(userObject); + // The app is ready to be used once the local session is logged in this.loginState.next(LoginState.LOGGED_IN); + await this.remoteSession.handleSuccessfulLogin(userObject); } /** @@ -111,7 +110,7 @@ export class SyncedSessionService extends SessionService { this.cancelLoginOfflineRetry(); // in case this is running in the background this.syncState.next(SyncState.UNSYNCED); - const remoteLogin = this._remoteSession + const remoteLogin = this.remoteSession .login(username, password) .then((state) => { this.updateLocalUser(password); @@ -120,7 +119,7 @@ export class SyncedSessionService extends SessionService { this.startSyncAfterLocalAndRemoteLogin(); - const localLoginState = await this._localSession.login(username, password); + const localLoginState = await this.localSession.login(username, password); if (localLoginState === LoginState.LOGGED_IN) { this.loginState.next(LoginState.LOGGED_IN); @@ -136,7 +135,7 @@ export class SyncedSessionService extends SessionService { const remoteLoginState = await remoteLogin; if (remoteLoginState === LoginState.LOGGED_IN) { // New user or password changed - const localLoginRetry = await this._localSession.login( + const localLoginRetry = await this.localSession.login( username, password ); @@ -157,14 +156,14 @@ export class SyncedSessionService extends SessionService { private startSyncAfterLocalAndRemoteLogin() { zip( - this._localSession.loginState.pipe(waitForChangeTo(LoginState.LOGGED_IN)), - this._remoteSession.loginState.pipe(waitForChangeTo(LoginState.LOGGED_IN)) + this.localSession.loginState.pipe(waitForChangeTo(LoginState.LOGGED_IN)), + this.remoteSession.loginState.pipe(waitForChangeTo(LoginState.LOGGED_IN)) ).subscribe(() => this.startSync()); } private handleRemotePasswordChange(username: string) { - this._localSession.logout(); - this._localSession.removeUser(username); + this.localSession.logout(); + this.localSession.removeUser(username); this.loginState.next(LoginState.LOGIN_FAILED); this.alertService.addDanger( $localize`Your password was changed recently. Please retry with your new password!` @@ -179,9 +178,9 @@ export class SyncedSessionService extends SessionService { private updateLocalUser(password: string) { // Update local user object - const remoteUser = this._remoteSession.getCurrentUser(); + const remoteUser = this.remoteSession.getCurrentUser(); if (remoteUser) { - this._localSession.saveUser(remoteUser, password); + this.localSession.saveUser(remoteUser, password); } } @@ -193,20 +192,20 @@ export class SyncedSessionService extends SessionService { } public getCurrentUser(): DatabaseUser { - return this._localSession.getCurrentUser(); + return this.localSession.getCurrentUser(); } public checkPassword(username: string, password: string): boolean { // This only checks the password against locally saved users - return this._localSession.checkPassword(username, password); + return this.localSession.checkPassword(username, password); } /** see {@link SessionService} */ public async sync(): Promise { this.syncState.next(SyncState.STARTED); try { - const localPouchDB = this._localSession.getDatabase().getPouchDB(); - const remotePouchDB = this._remoteSession.getDatabase().getPouchDB(); + const localPouchDB = this.localSession.getDatabase().getPouchDB(); + const remotePouchDB = this.remoteSession.getDatabase().getPouchDB(); const result = await localPouchDB.sync(remotePouchDB, { batch_size: this.POUCHDB_SYNC_BATCH_SIZE, }); @@ -224,15 +223,15 @@ export class SyncedSessionService extends SessionService { public liveSync() { this.cancelLiveSync(); // cancel any liveSync that may have been alive before this.syncState.next(SyncState.STARTED); - const localPouchDB = this._localSession.getDatabase().getPouchDB(); - const remotePouchDB = this._remoteSession.getDatabase().getPouchDB(); + const localPouchDB = this.localSession.getDatabase().getPouchDB(); + const remotePouchDB = this.remoteSession.getDatabase().getPouchDB(); this._liveSyncHandle = (localPouchDB.sync(remotePouchDB, { live: true, retry: true, }) as any) .on("paused", (info) => { // replication was paused: either because sync is finished or because of a failed sync (mostly due to lost connection). info is empty. - if (this._remoteSession.loginState.value === LoginState.LOGGED_IN) { + if (this.remoteSession.loginState.value === LoginState.LOGGED_IN) { this.syncState.next(SyncState.COMPLETED); // We might end up here after a failed sync that is not due to offline errors. // It shouldn't happen too often, as we have an initial non-live sync to catch those situations, but we can't find that out here @@ -261,7 +260,6 @@ export class SyncedSessionService extends SessionService { * @param timeout ms to wait before starting the liveSync */ public liveSyncDeferred(timeout = 1000) { - this.cancelLiveSync(); // cancel any liveSync that may have been alive before this._liveSyncScheduledHandle = setTimeout(() => this.liveSync(), timeout); } @@ -284,6 +282,7 @@ export class SyncedSessionService extends SessionService { if (this._liveSyncHandle) { this._liveSyncHandle.cancel(); } + this.syncState.next(SyncState.UNSYNCED); } /** @@ -291,18 +290,19 @@ export class SyncedSessionService extends SessionService { * als see {@link SessionService} */ public getDatabase(): Database { - return this._localSession.getDatabase(); + return this.localSession.getDatabase(); } /** * Logout and stop any existing sync. * also see {@link SessionService} */ - public logout() { + public async logout() { this.cancelLoginOfflineRetry(); this.cancelLiveSync(); + this.localSession.logout(); + await this.remoteSession.logout(); + this.location.reload(); this.loginState.next(LoginState.LOGGED_OUT); - this._localSession.logout(); - this._remoteSession.logout(); } } diff --git a/src/app/core/session/session.module.ts b/src/app/core/session/session.module.ts index 9244c1e62a..2947ee0733 100644 --- a/src/app/core/session/session.module.ts +++ b/src/app/core/session/session.module.ts @@ -15,13 +15,12 @@ * along with ndb-core. If not, see . */ -import { NgModule } from "@angular/core"; +import { Injector, NgModule } from "@angular/core"; import { CommonModule } from "@angular/common"; import { LoginComponent } from "./login/login.component"; import { FormsModule } from "@angular/forms"; import { EntityModule } from "../entity/entity.module"; import { AlertsModule } from "../alerts/alerts.module"; -import { sessionServiceProvider } from "./session.service.provider"; import { UserModule } from "../user/user.module"; import { MatButtonModule } from "@angular/material/button"; import { MatCardModule } from "@angular/material/card"; @@ -29,13 +28,19 @@ import { MatFormFieldModule } from "@angular/material/form-field"; import { MatInputModule } from "@angular/material/input"; import { RouterModule } from "@angular/router"; import { HttpClientModule } from "@angular/common/http"; -import { Database } from "../database/database"; -import { PouchDatabase } from "../database/pouch-database"; import { MatDialogModule } from "@angular/material/dialog"; import { MatProgressBarModule } from "@angular/material/progress-bar"; +import { SyncedSessionService } from "./session-service/synced-session.service"; +import { LocalSession } from "./session-service/local-session"; +import { RemoteSession } from "./session-service/remote-session"; +import { SessionService } from "./session-service/session.service"; +import { AppConfig } from "../app-config/app-config"; +import { SessionType } from "./session-type"; /** * The core session logic handling user login as well as connection and synchronization with the remote database. + * To access the currently active session inject the `SessionService` into your component/service. + * What session you get varies depending on the `session_type` setting in the `config.json`. * * A detailed discussion about the Session concept is available separately: * [Session Handling, Authentication & Synchronisation]{@link /additional-documentation/concepts/session-and-authentication-system.html} @@ -59,8 +64,20 @@ import { MatProgressBarModule } from "@angular/material/progress-bar"; declarations: [LoginComponent], exports: [LoginComponent], providers: [ - sessionServiceProvider, - { provide: Database, useClass: PouchDatabase }, + SyncedSessionService, + LocalSession, + RemoteSession, + { + provide: SessionService, + useFactory: (injector: Injector) => { + if (AppConfig?.settings?.session_type === SessionType.synced) { + return injector.get(SyncedSessionService); + } else { + return injector.get(LocalSession); + } + }, + deps: [Injector], + }, ], }) export class SessionModule {} diff --git a/src/app/core/session/session.service.provider.ts b/src/app/core/session/session.service.provider.ts deleted file mode 100644 index 3c0c9fe1a6..0000000000 --- a/src/app/core/session/session.service.provider.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * This file is part of ndb-core. - * - * ndb-core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * ndb-core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with ndb-core. If not, see . - */ - -import { SyncedSessionService } from "./session-service/synced-session.service"; -import { AppConfig } from "../app-config/app-config"; -import { SessionService } from "./session-service/session.service"; -import { AlertService } from "../alerts/alert.service"; -import { LoggingService } from "../logging/logging.service"; -import { SessionType } from "./session-type"; -import { HttpClient } from "@angular/common/http"; -import { LocalSession } from "./session-service/local-session"; -import { Database } from "../database/database"; -import { PouchDatabase } from "../database/pouch-database"; - -/** - * Factory method for Angular DI provider of SessionService. - * - * see [sessionServiceProvider]{@link sessionServiceProvider} for details. - */ -export function sessionServiceFactory( - alertService: AlertService, - loggingService: LoggingService, - httpClient: HttpClient, - database: Database -): SessionService { - const pouchDatabase = database as PouchDatabase; - if (AppConfig.settings.session_type === SessionType.synced) { - return new SyncedSessionService( - alertService, - loggingService, - httpClient, - pouchDatabase - ); - } else { - return new LocalSession(pouchDatabase); - } - // TODO: requires a configuration or UI option to select RemoteSession: https://github.com/Aam-Digital/ndb-core/issues/434 - // return new RemoteSession(httpClient, loggingService); -} - -/** - * Provider for SessionService implementation based on the AppConfig settings. - * - * Set `"database": { "useTemporaryDatabase": true }` in your app-config.json - * to use the MockSessionService which will set up an in-memory database with demo data. - * Otherwise the SyncedSessionService is used, establishing a local and remote session and setting up sync between them. - */ -export const sessionServiceProvider = { - provide: SessionService, - useFactory: sessionServiceFactory, - deps: [AlertService, LoggingService, HttpClient, Database], -}; diff --git a/src/app/core/sync-status/sync-status.module.ts b/src/app/core/sync-status/sync-status.module.ts index 8d9e5cff0a..21f9e1cd32 100644 --- a/src/app/core/sync-status/sync-status.module.ts +++ b/src/app/core/sync-status/sync-status.module.ts @@ -23,7 +23,6 @@ import { AlertsModule } from "../alerts/alerts.module"; import { MatButtonModule } from "@angular/material/button"; import { MatDialogModule } from "@angular/material/dialog"; import { MatProgressBarModule } from "@angular/material/progress-bar"; -import { InitialSyncDialogComponent } from "./sync-status/initial-sync-dialog.component"; import { MatBadgeModule } from "@angular/material/badge"; import { MatMenuModule } from "@angular/material/menu"; import { BackgroundProcessingIndicatorComponent } from "./background-processing-indicator/background-processing-indicator.component"; @@ -47,11 +46,7 @@ import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; MatTooltipModule, FontAwesomeModule, ], - declarations: [ - InitialSyncDialogComponent, - SyncStatusComponent, - BackgroundProcessingIndicatorComponent, - ], + declarations: [SyncStatusComponent, BackgroundProcessingIndicatorComponent], exports: [SyncStatusComponent], providers: [], }) diff --git a/src/app/core/sync-status/sync-status/initial-sync-dialog.component.html b/src/app/core/sync-status/sync-status/initial-sync-dialog.component.html deleted file mode 100644 index 77b25d50fb..0000000000 --- a/src/app/core/sync-status/sync-status/initial-sync-dialog.component.html +++ /dev/null @@ -1,10 +0,0 @@ -

- Downloading Initial Database ... -

-
-

Synchronizing with remote database.

-

- Login may not be possible until this is completed. -

-

-
diff --git a/src/app/core/sync-status/sync-status/initial-sync-dialog.component.ts b/src/app/core/sync-status/sync-status/initial-sync-dialog.component.ts deleted file mode 100644 index e0c514376d..0000000000 --- a/src/app/core/sync-status/sync-status/initial-sync-dialog.component.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * This file is part of ndb-core. - * - * ndb-core is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * ndb-core is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with ndb-core. If not, see . - */ - -import { Component } from "@angular/core"; -import { MatDialogRef } from "@angular/material/dialog"; - -/** - * Content for the dialog being displayed during an initial synchronization which blocks login - * (because user accounts need to be synced first). - */ -@Component({ - templateUrl: "./initial-sync-dialog.component.html", -}) -export class InitialSyncDialogComponent { - /** - * This component is usually instanciated through the MatDialog service which provides the necessary paramters. - * @param dialogRef Reference to the dialog in which the component is being displayed. - */ - constructor(public dialogRef: MatDialogRef) { - this.dialogRef.disableClose = true; - } -} diff --git a/src/app/core/sync-status/sync-status/sync-status.component.spec.ts b/src/app/core/sync-status/sync-status/sync-status.component.spec.ts index 41226af647..95ed24f1c4 100644 --- a/src/app/core/sync-status/sync-status/sync-status.component.spec.ts +++ b/src/app/core/sync-status/sync-status/sync-status.component.spec.ts @@ -83,20 +83,6 @@ describe("SyncStatusComponent", () => { expect(component).toBeTruthy(); }); - it("should open dialog without error", async () => { - mockSessionService.syncState.next(SyncState.STARTED); - - fixture.detectChanges(); - await fixture.whenStable(); - // @ts-ignore - expect(component.dialogRef).toBeDefined(); - - mockSessionService.syncState.next(SyncState.COMPLETED); - - fixture.detectChanges(); - await fixture.whenStable(); - }); - it("should update backgroundProcesses details on sync", async () => { mockSessionService.syncState.next(SyncState.STARTED); fixture.detectChanges(); diff --git a/src/app/core/sync-status/sync-status/sync-status.component.ts b/src/app/core/sync-status/sync-status/sync-status.component.ts index fa9f175746..0b2e7c9c52 100644 --- a/src/app/core/sync-status/sync-status/sync-status.component.ts +++ b/src/app/core/sync-status/sync-status/sync-status.component.ts @@ -15,17 +15,13 @@ * along with ndb-core. If not, see . */ -import { Component, OnInit } from "@angular/core"; +import { Component } from "@angular/core"; import { SessionService } from "../../session/session-service/session.service"; import { SyncState } from "../../session/session-states/sync-state.enum"; -import { AlertService } from "../../alerts/alert.service"; -import { MatDialog, MatDialogRef } from "@angular/material/dialog"; -import { InitialSyncDialogComponent } from "./initial-sync-dialog.component"; import { DatabaseIndexingService } from "../../entity/database-indexing/database-indexing.service"; import { BackgroundProcessState } from "../background-process-state.interface"; import { BehaviorSubject } from "rxjs"; import { debounceTime } from "rxjs/operators"; -import { LoggingService } from "../../logging/logging.service"; /** * A small indicator component that displays an icon when there is currently synchronization @@ -39,77 +35,38 @@ import { LoggingService } from "../../logging/logging.service"; templateUrl: "./sync-status.component.html", styleUrls: ["./sync-status.component.scss"], }) -export class SyncStatusComponent implements OnInit { - private syncInProgress: boolean; +export class SyncStatusComponent { private indexingProcesses: BackgroundProcessState[]; - private _backgroundProcesses: BehaviorSubject< - BackgroundProcessState[] - > = new BehaviorSubject([]); + private _backgroundProcesses = new BehaviorSubject( + [] + ); /** background processes to be displayed to users, with short delay to avoid flickering */ backgroundProcesses = this._backgroundProcesses .asObservable() .pipe(debounceTime(1000)); - private dialogRef: MatDialogRef; - constructor( - public dialog: MatDialog, private sessionService: SessionService, - private dbIndexingService: DatabaseIndexingService, - private alertService: AlertService, - private loggingService: LoggingService - ) {} - - ngOnInit(): void { - this.dbIndexingService.indicesRegistered.subscribe((indicesStatus) => - this.handleIndexingState(indicesStatus) - ); + private dbIndexingService: DatabaseIndexingService + ) { + this.dbIndexingService.indicesRegistered.subscribe((indicesStatus) => { + this.indexingProcesses = indicesStatus; + this.updateBackgroundProcessesList(); + }); - this.sessionService.syncState.subscribe((state) => - this.handleSyncState(state) + this.sessionService.syncState.subscribe(() => + this.updateBackgroundProcessesList() ); } - private handleSyncState(state: SyncState) { - switch (state) { - case SyncState.STARTED: - this.syncInProgress = true; - if (!this.sessionService.isLoggedIn() && !this.dialogRef) { - this.dialogRef = this.dialog.open(InitialSyncDialogComponent); - } - break; - case SyncState.COMPLETED: - this.syncInProgress = false; - if (this.dialogRef) { - this.dialogRef.close(); - this.dialogRef = undefined; - } - this.loggingService.debug("Database sync completed."); - break; - case SyncState.FAILED: - this.syncInProgress = false; - if (this.dialogRef) { - this.dialogRef.close(); - this.dialogRef = undefined; - } - break; - } - this.updateBackgroundProcessesList(); - } - - private handleIndexingState(indicesStatus: BackgroundProcessState[]) { - this.indexingProcesses = indicesStatus; - this.updateBackgroundProcessesList(); - } - /** * Build and emit an updated array of current background processes * @private */ private updateBackgroundProcessesList() { let currentProcesses: BackgroundProcessState[] = []; - if (this.syncInProgress) { + if (this.sessionService.syncState.value === SyncState.STARTED) { currentProcesses.push({ title: $localize`Synchronizing database`, pending: true, diff --git a/src/app/core/ui/search/search.component.spec.ts b/src/app/core/ui/search/search.component.spec.ts index 20bcd1737c..18d60669f5 100644 --- a/src/app/core/ui/search/search.component.spec.ts +++ b/src/app/core/ui/search/search.component.spec.ts @@ -1,35 +1,21 @@ import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; import { SearchComponent } from "./search.component"; -import { MatAutocompleteModule } from "@angular/material/autocomplete"; -import { MatFormFieldModule } from "@angular/material/form-field"; -import { MatInputModule } from "@angular/material/input"; -import { MatToolbarModule } from "@angular/material/toolbar"; -import { FormsModule, ReactiveFormsModule } from "@angular/forms"; -import { CommonModule } from "@angular/common"; -import { NoopAnimationsModule } from "@angular/platform-browser/animations"; -import { ChildrenModule } from "../../../child-dev-project/children/children.module"; -import { SchoolsModule } from "../../../child-dev-project/schools/schools.module"; -import { EntitySchemaService } from "../../entity/schema/entity-schema.service"; import { Child } from "../../../child-dev-project/children/model/child"; import { School } from "../../../child-dev-project/schools/model/school"; -import { RouterTestingModule } from "@angular/router/testing"; import { DatabaseIndexingService } from "../../entity/database-indexing/database-indexing.service"; -import { EntityUtilsModule } from "../../entity-components/entity-utils/entity-utils.module"; import { Subscription } from "rxjs"; import { Entity } from "../../entity/model/entity"; -import { EntityMapperService } from "../../entity/entity-mapper.service"; -import { - EntityRegistry, - entityRegistry, -} from "../../entity/database-entity.decorator"; +import { UiModule } from "../ui.module"; +import { MockedTestingModule } from "../../../utils/mocked-testing.module"; +import { SwUpdate } from "@angular/service-worker"; +import { FontAwesomeTestingModule } from "@fortawesome/angular-fontawesome/testing"; describe("SearchComponent", () => { let component: SearchComponent; let fixture: ComponentFixture; let mockIndexService: jasmine.SpyObj; - const entitySchemaService = new EntitySchemaService(); let subscription: Subscription; beforeEach( @@ -41,35 +27,20 @@ describe("SearchComponent", () => { TestBed.configureTestingModule({ imports: [ - MatFormFieldModule, - MatInputModule, - MatAutocompleteModule, - CommonModule, - FormsModule, - NoopAnimationsModule, - ChildrenModule, - SchoolsModule, - MatToolbarModule, - RouterTestingModule, - ReactiveFormsModule, - EntityUtilsModule, + UiModule, + MockedTestingModule.withState(), + FontAwesomeTestingModule, ], providers: [ - { provide: EntitySchemaService, useValue: entitySchemaService }, { provide: DatabaseIndexingService, useValue: mockIndexService }, - { provide: EntityMapperService, useValue: {} }, - { - provide: EntityRegistry, - useValue: entityRegistry, - }, + { provide: SwUpdate, useValue: {} }, ], - declarations: [SearchComponent], }).compileComponents(); }) ); beforeEach(() => { - mockIndexService.createIndex.and.returnValue(Promise.resolve()); + mockIndexService.createIndex.and.resolveTo(); fixture = TestBed.createComponent(SearchComponent); component = fixture.componentInstance; fixture.detectChanges(); diff --git a/src/app/core/view/dynamic-routing/empty/application-loading.component.html b/src/app/core/view/dynamic-routing/empty/application-loading.component.html new file mode 100644 index 0000000000..3c64434ff4 --- /dev/null +++ b/src/app/core/view/dynamic-routing/empty/application-loading.component.html @@ -0,0 +1,7 @@ +
+

Building application

+

+ The application will be ready in a moment +

+ +
diff --git a/src/app/core/view/dynamic-routing/empty/application-loading.component.ts b/src/app/core/view/dynamic-routing/empty/application-loading.component.ts new file mode 100644 index 0000000000..1a650f6755 --- /dev/null +++ b/src/app/core/view/dynamic-routing/empty/application-loading.component.ts @@ -0,0 +1,6 @@ +import { Component } from "@angular/core"; + +@Component({ + templateUrl: "./application-loading.component.html", +}) +export class ApplicationLoadingComponent {} diff --git a/src/app/core/view/dynamic-routing/not-found/not-found.component.html b/src/app/core/view/dynamic-routing/not-found/not-found.component.html new file mode 100644 index 0000000000..db2a4e800c --- /dev/null +++ b/src/app/core/view/dynamic-routing/not-found/not-found.component.html @@ -0,0 +1,6 @@ +
+

Page Not Found

+

404

+

Either the page has been removed or you don't have the required permissions to view this page.

+ +
diff --git a/src/app/core/view/dynamic-routing/not-found/not-found.component.scss b/src/app/core/view/dynamic-routing/not-found/not-found.component.scss new file mode 100644 index 0000000000..e6100040e2 --- /dev/null +++ b/src/app/core/view/dynamic-routing/not-found/not-found.component.scss @@ -0,0 +1,4 @@ +.error-code { + color: darkgray; + margin-top: -16px; +} diff --git a/src/app/core/view/dynamic-routing/not-found/not-found.component.spec.ts b/src/app/core/view/dynamic-routing/not-found/not-found.component.spec.ts new file mode 100644 index 0000000000..13ebb49c09 --- /dev/null +++ b/src/app/core/view/dynamic-routing/not-found/not-found.component.spec.ts @@ -0,0 +1,26 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { NotFoundComponent } from "./not-found.component"; +import { RouterTestingModule } from "@angular/router/testing"; +import { ViewModule } from "../../view.module"; + +describe("NotFoundComponent", () => { + let component: NotFoundComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ViewModule, RouterTestingModule], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(NotFoundComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/core/view/dynamic-routing/not-found/not-found.component.ts b/src/app/core/view/dynamic-routing/not-found/not-found.component.ts new file mode 100644 index 0000000000..aaf0dfd051 --- /dev/null +++ b/src/app/core/view/dynamic-routing/not-found/not-found.component.ts @@ -0,0 +1,8 @@ +import { Component } from "@angular/core"; + +@Component({ + selector: "app-not-found", + templateUrl: "./not-found.component.html", + styleUrls: ["./not-found.component.scss"], +}) +export class NotFoundComponent {} diff --git a/src/app/core/view/dynamic-routing/router.service.spec.ts b/src/app/core/view/dynamic-routing/router.service.spec.ts index dfc247c75a..b5efbed1ce 100644 --- a/src/app/core/view/dynamic-routing/router.service.spec.ts +++ b/src/app/core/view/dynamic-routing/router.service.spec.ts @@ -1,6 +1,6 @@ import { Component } from "@angular/core"; import { TestBed } from "@angular/core/testing"; -import { Router } from "@angular/router"; +import { Route, Router } from "@angular/router"; import { RouterTestingModule } from "@angular/router/testing"; import { ChildrenListComponent } from "../../../child-dev-project/children/children-list/children-list.component"; import { AdminComponent } from "../../admin/admin/admin.component"; @@ -12,6 +12,8 @@ import { EntityDetailsComponent } from "../../entity-components/entity-details/e import { ViewConfig } from "./view-config.interface"; import { UserRoleGuard } from "../../permissions/permission-guard/user-role.guard"; import { RouteRegistry, routesRegistry } from "../../../app.routing"; +import { ApplicationLoadingComponent } from "./empty/application-loading.component"; +import { NotFoundComponent } from "./not-found/not-found.component"; class TestComponent extends Component {} @@ -87,18 +89,29 @@ describe("RouterService", () => { expect(router.resetConfig).toHaveBeenCalledWith(expectedRoutes); }); - it("should ignore a view config route of hard-coded route already exists", () => { - const existingRoutes = [{ path: "other", component: TestComponent }]; - const testViewConfigs = [ - { _id: "view:child", component: "ChildrenList" }, - { _id: "view:other", component: "EntityDetails" }, - ]; - const expectedRoutes = [ - { path: "child", component: ChildrenListComponent, data: {} }, + it("should extend a view config route of lazy loaded routes (hard coded)", () => { + const existingRoutes: Route[] = [ { path: "other", component: TestComponent }, + { path: "child", component: ChildrenListComponent }, + ]; + const testViewConfigs: ViewConfig[] = [ + { + _id: "view:other", + permittedUserRoles: ["admin_app"], + lazyLoaded: true, + }, + ]; + const expectedRoutes: Route[] = [ + { + path: "other", + component: TestComponent, + canActivate: [UserRoleGuard], + data: { permittedUserRoles: ["admin_app"] }, + }, + { path: "child", component: ChildrenListComponent }, ]; - const router = TestBed.inject(Router); + const router = TestBed.inject(Router); spyOn(router, "resetConfig"); service.reloadRouting(testViewConfigs, existingRoutes); @@ -150,4 +163,15 @@ describe("RouterService", () => { expect(router.resetConfig).toHaveBeenCalledWith(expectedRoutes); }); + + it("should set NotFoundComponent for wildcard route", () => { + const wildcardRoute: Route = { + path: "**", + component: ApplicationLoadingComponent, + }; + + service.reloadRouting([], [wildcardRoute]); + + expect(wildcardRoute).toEqual({ path: "**", component: NotFoundComponent }); + }); }); diff --git a/src/app/core/view/dynamic-routing/router.service.ts b/src/app/core/view/dynamic-routing/router.service.ts index 38d1741875..3961c19b24 100644 --- a/src/app/core/view/dynamic-routing/router.service.ts +++ b/src/app/core/view/dynamic-routing/router.service.ts @@ -9,6 +9,7 @@ import { } from "./view-config.interface"; import { UserRoleGuard } from "../../permissions/permission-guard/user-role.guard"; import { RouteRegistry } from "../../../app.routing"; +import { NotFoundComponent } from "./not-found/not-found.component"; /** * The RouterService dynamically sets up Angular routing from config loaded through the {@link ConfigService}. @@ -34,7 +35,7 @@ export class RouterService { const viewConfigs = this.configService.getAllConfigs( PREFIX_VIEW_CONFIG ); - this.reloadRouting(viewConfigs, this.router.config, true); + this.reloadRouting(viewConfigs, this.router.config); } /** @@ -42,53 +43,43 @@ export class RouterService { * * @param viewConfigs The configs loaded from the ConfigService * @param additionalRoutes Optional array of routes to keep in addition to the ones loaded from config - * @param overwriteExistingRoutes Optionally set to true if config was updated and previously existing routes shall be updated */ - reloadRouting( - viewConfigs: ViewConfig[], - additionalRoutes: Route[] = [], - overwriteExistingRoutes = false - ) { + reloadRouting(viewConfigs: ViewConfig[], additionalRoutes: Route[] = []) { const routes: Route[] = []; for (const view of viewConfigs) { if (view.lazyLoaded) { - // lazy-loaded views' routing is still hardcoded in the app.routing - continue; + const path = view._id.substring(PREFIX_VIEW_CONFIG.length); + const route = additionalRoutes.find((r) => r.path === path); + routes.push(this.generateRouteFromConfig(view, route)); + } else { + routes.push(this.generateRouteFromConfig(view)); } - const route = this.generateRouteFromConfig(view); - - if ( - !overwriteExistingRoutes && - additionalRoutes.find((r) => r.path === route.path) - ) { - this.loggingService.warn( - "ignoring route from view config because the path is already defined: " + - view._id - ); - continue; - } - - routes.push(route); } // add routes from other sources (e.g. pre-existing hard-coded routes) const noDuplicates = additionalRoutes.filter( (r) => !routes.find((o) => o.path === r.path) ); + + // change wildcard route to show not-found component instead of empty page + const wildcardRoute = noDuplicates.find((route) => route.path === "**"); + if (wildcardRoute) { + wildcardRoute.component = NotFoundComponent; + } + routes.push(...noDuplicates); this.router.resetConfig(routes); } - private generateRouteFromConfig(view: ViewConfig): Route { - const path = view._id.substring(PREFIX_VIEW_CONFIG.length); // remove prefix to get actual path - - const route: Route = { - path: path, + private generateRouteFromConfig( + view: ViewConfig, + route: Route = { + path: view._id.substring(PREFIX_VIEW_CONFIG.length), component: this.registry.get(view.component), - }; - + } + ): Route { const routeData: RouteData = {}; if (view.permittedUserRoles) { diff --git a/src/app/core/view/dynamic-routing/view-config.interface.ts b/src/app/core/view/dynamic-routing/view-config.interface.ts index 95a097f80c..bc20431293 100644 --- a/src/app/core/view/dynamic-routing/view-config.interface.ts +++ b/src/app/core/view/dynamic-routing/view-config.interface.ts @@ -9,8 +9,11 @@ export interface ViewConfig { /** * string id/name of the component to be displaying this view. * The component id has to be registered in the component map. + * + * (optional) if the `ladyLoaded` is true, this is not required (and will be ignored) + * This allows hard-coded lazy-loaded components to be dynamically extended with config or permissions. */ - component: string; + component?: string; /** * Allows to restrict the route to the given list of user roles. diff --git a/src/app/core/view/view.module.ts b/src/app/core/view/view.module.ts index 0219ee6730..45860061d1 100644 --- a/src/app/core/view/view.module.ts +++ b/src/app/core/view/view.module.ts @@ -10,6 +10,11 @@ import { viewRegistry, ViewRegistry, } from "./dynamic-components/dynamic-component.decorator"; +import { ApplicationLoadingComponent } from "./dynamic-routing/empty/application-loading.component"; +import { NotFoundComponent } from "./dynamic-routing/not-found/not-found.component"; +import { RouterModule } from "@angular/router"; +import { FlexModule } from "@angular/flex-layout"; +import { MatProgressBarModule } from "@angular/material/progress-bar"; /** * Generic components and services to allow assembling the app dynamically from config objects. @@ -19,13 +24,24 @@ import { DynamicComponentDirective, FaDynamicIconComponent, ViewTitleComponent, + ApplicationLoadingComponent, + NotFoundComponent, + ], + imports: [ + CommonModule, + FontAwesomeModule, + MatTooltipModule, + MatButtonModule, + RouterModule, + FlexModule, + MatProgressBarModule, ], - imports: [CommonModule, FontAwesomeModule, MatTooltipModule, MatButtonModule], providers: [{ provide: ViewRegistry, useValue: viewRegistry }], exports: [ DynamicComponentDirective, FaDynamicIconComponent, ViewTitleComponent, + ApplicationLoadingComponent, ], }) export class ViewModule {} diff --git a/src/app/features/historical-data/demo-historical-data-generator.ts b/src/app/features/historical-data/demo-historical-data-generator.ts index a70a928088..5c59d43fb5 100644 --- a/src/app/features/historical-data/demo-historical-data-generator.ts +++ b/src/app/features/historical-data/demo-historical-data-generator.ts @@ -2,9 +2,10 @@ import { DemoDataGenerator } from "../../core/demo-data/demo-data-generator"; import { HistoricalEntityData } from "./model/historical-entity-data"; import { Injectable } from "@angular/core"; import { DemoChildGenerator } from "../../child-dev-project/children/demo-data-generators/demo-child-generator.service"; -import { ConfigService } from "../../core/config/config.service"; import { faker } from "../../core/demo-data/faker"; import { ENTITY_CONFIG_PREFIX } from "../../core/entity/model/entity"; +import { DemoConfigGeneratorService } from "../../core/config/demo-config-generator.service"; +import { ratingAnswers } from "./model/rating-answers"; export class DemoHistoricalDataConfig { minCountAttributes: number; @@ -25,19 +26,17 @@ export class DemoHistoricalDataGenerator extends DemoDataGenerator(ENTITY_CONFIG_PREFIX + HistoricalEntityData.ENTITY_TYPE) - .attributes.map((attr) => attr.name); - const ratingAnswer = this.configService.getConfigurableEnumValues( - "rating-answer" - ); + const config = this.configGenerator.entities[0]; + const attributes: any[] = config.data[ + ENTITY_CONFIG_PREFIX + HistoricalEntityData.ENTITY_TYPE + ].attributes.map((attr) => attr.name); const entities: HistoricalEntityData[] = []; for (const child of this.childrenGenerator.entities) { const countOfData = @@ -48,7 +47,7 @@ export class DemoHistoricalDataGenerator extends DemoDataGenerator { let component: ProgressDashboardComponent; @@ -32,7 +33,11 @@ describe("ProgressDashboardComponent", () => { mockEntityMapper.save.and.resolveTo(); TestBed.configureTestingModule({ - imports: [ProgressDashboardWidgetModule, FontAwesomeTestingModule], + imports: [ + ProgressDashboardWidgetModule, + MockedTestingModule.withState(), + FontAwesomeTestingModule, + ], providers: [ { provide: EntityMapperService, useValue: mockEntityMapper }, { provide: MatDialog, useValue: mockDialog }, diff --git a/src/app/features/reporting/query.service.spec.ts b/src/app/features/reporting/query.service.spec.ts index d5f4e87e09..ffe571b078 100644 --- a/src/app/features/reporting/query.service.spec.ts +++ b/src/app/features/reporting/query.service.spec.ts @@ -48,13 +48,8 @@ describe("QueryService", () => { beforeEach(async () => { TestBed.configureTestingModule({ - imports: [ConfigurableEnumModule, DatabaseTestingModule, ChildrenModule], - providers: [ - ChildrenService, - AttendanceService, - ConfigService, - EntityConfigService, - ], + imports: [DatabaseTestingModule, ConfigurableEnumModule, ChildrenModule], + providers: [ChildrenService, AttendanceService, EntityConfigService], }); service = TestBed.inject(QueryService); const configService = TestBed.inject(ConfigService); diff --git a/src/app/utils/database-testing.module.ts b/src/app/utils/database-testing.module.ts index 99951a1840..fbf0cc55c4 100644 --- a/src/app/utils/database-testing.module.ts +++ b/src/app/utils/database-testing.module.ts @@ -16,6 +16,10 @@ import { ViewRegistry, } from "../core/view/dynamic-components/dynamic-component.decorator"; import { RouteRegistry, routesRegistry } from "../app.routing"; +import { + ConfigService, + createTestingConfigService, +} from "../core/config/config.service"; /** * Utility module that creates a simple environment where a correctly configured database and session is set up. @@ -30,23 +34,20 @@ import { RouteRegistry, routesRegistry } from "../app.routing"; @NgModule({ providers: [ LoggingService, - { - provide: Database, - useFactory: (loggingService: LoggingService) => - new PouchDatabase(loggingService).initInMemoryDB(), - deps: [LoggingService], - }, + PouchDatabase, + { provide: Database, useExisting: PouchDatabase }, EntityMapperService, EntitySchemaService, - { - provide: SessionService, - useFactory: (database: PouchDatabase) => new LocalSession(database), - deps: [Database], - }, + { provide: SessionService, useClass: LocalSession }, DatabaseIndexingService, { provide: EntityRegistry, useValue: entityRegistry }, { provide: ViewRegistry, useValue: viewRegistry }, { provide: RouteRegistry, useValue: routesRegistry }, + { provide: ConfigService, useValue: createTestingConfigService() }, ], }) -export class DatabaseTestingModule {} +export class DatabaseTestingModule { + constructor(pouchDatabase: PouchDatabase) { + pouchDatabase.initInMemoryDB(); + } +} diff --git a/src/app/utils/mocked-testing.module.ts b/src/app/utils/mocked-testing.module.ts index 8e95f7e4f8..fa0c092247 100644 --- a/src/app/utils/mocked-testing.module.ts +++ b/src/app/utils/mocked-testing.module.ts @@ -29,6 +29,10 @@ import { } from "../core/view/dynamic-components/dynamic-component.decorator"; import { RouteRegistry, routesRegistry } from "../app.routing"; import { MatNativeDateModule } from "@angular/material/core"; +import { + ConfigService, + createTestingConfigService, +} from "../core/config/config.service"; export const TEST_USER = "test"; export const TEST_PASSWORD = "pass"; @@ -101,6 +105,7 @@ export class MockedTestingModule { useValue: session, }, { provide: EntityMapperService, useValue: mockedEntityMapper }, + { provide: ConfigService, useValue: createTestingConfigService() }, { provide: Database, useValue: session.getDatabase() }, ], }; diff --git a/src/main.ts b/src/main.ts index b87fbe2fcb..4d883308fe 100644 --- a/src/main.ts +++ b/src/main.ts @@ -20,6 +20,7 @@ import { platformBrowserDynamic } from "@angular/platform-browser-dynamic"; import { AppModule } from "./app/app.module"; import { environment } from "./environments/environment"; +import { AppConfig } from "./app/core/app-config/app-config"; // Import hammer.js to enable gestures // on mobile devices @@ -29,4 +30,9 @@ if (environment.production) { enableProdMode(); } -platformBrowserDynamic().bootstrapModule(AppModule); +/** + * Loading AppConfig before bootstrap process (see {@link https://stackoverflow.com/a/66957293/10713841}) + */ +AppConfig.load().then(() => + platformBrowserDynamic().bootstrapModule(AppModule) +);