From a4489d72fcc1074823a7aeed8e1820bafb56b437 Mon Sep 17 00:00:00 2001 From: Tom Winter Date: Wed, 28 Feb 2024 14:11:17 +0100 Subject: [PATCH] test: add test cases --- src/config/app.yaml | 2 +- src/couchdb/couch-db-client.interface.ts | 25 ++ src/couchdb/couch-db-client.service.ts | 10 +- src/query/sqs/dtos.ts | 42 ++- .../sqs/sqs-schema-generator.service.spec.ts | 315 ++++++++++++------ src/query/sqs/sqs-schema-generator.service.ts | 77 +++-- 6 files changed, 329 insertions(+), 142 deletions(-) create mode 100644 src/couchdb/couch-db-client.interface.ts diff --git a/src/config/app.yaml b/src/config/app.yaml index d7cb93c..8d7f34c 100644 --- a/src/config/app.yaml +++ b/src/config/app.yaml @@ -30,4 +30,4 @@ SENTRY: ENABLED: false INSTANCE_NAME: local-development # can be personalised in .env -> local-development- ENVIRONMENT: local # local | development | production - DNS: + DNS: '' diff --git a/src/couchdb/couch-db-client.interface.ts b/src/couchdb/couch-db-client.interface.ts new file mode 100644 index 0000000..f8fb080 --- /dev/null +++ b/src/couchdb/couch-db-client.interface.ts @@ -0,0 +1,25 @@ +import { Observable } from 'rxjs'; +import { CouchDbChangesResponse } from './dtos'; +import { AxiosResponse } from 'axios'; + +export interface ICouchDbClient { + changes(request: { config?: any }): Observable; + + headDatabaseDocument(request: { + documentId: string; + config?: any; + }): Observable>; + + getDatabaseDocument(request: { + documentId: string; + config?: any; + }): Observable; + + find(request: { query: object; config: any }): Observable; + + putDatabaseDocument(request: { + documentId: string; + body: any; + config: any; + }): Observable; +} diff --git a/src/couchdb/couch-db-client.service.ts b/src/couchdb/couch-db-client.service.ts index fbd226f..8706c82 100644 --- a/src/couchdb/couch-db-client.service.ts +++ b/src/couchdb/couch-db-client.service.ts @@ -1,8 +1,9 @@ import { Logger } from '@nestjs/common'; import { catchError, map, Observable, of, switchMap } from 'rxjs'; import { HttpService } from '@nestjs/axios'; -import { AxiosHeaders } from 'axios'; +import { AxiosHeaders, AxiosResponse } from 'axios'; import { CouchDbChangesResponse } from './dtos'; +import { ICouchDbClient } from './couch-db-client.interface'; export class CouchDbClientConfig { BASE_URL = ''; @@ -11,7 +12,7 @@ export class CouchDbClientConfig { BASIC_AUTH_PASSWORD = ''; } -export class CouchDbClient { +export class CouchDbClient implements ICouchDbClient { private readonly logger = new Logger(CouchDbClient.name); constructor(private httpService: HttpService) {} @@ -30,7 +31,10 @@ export class CouchDbClient { ); } - headDatabaseDocument(request: { documentId: string; config?: any }) { + headDatabaseDocument(request: { + documentId: string; + config?: any; + }): Observable> { return this.httpService.head(`${request.documentId}`, request.config).pipe( catchError((err) => { if (err.response.status !== 404) { diff --git a/src/query/sqs/dtos.ts b/src/query/sqs/dtos.ts index ad52b82..ee6268a 100644 --- a/src/query/sqs/dtos.ts +++ b/src/query/sqs/dtos.ts @@ -1,15 +1,37 @@ +import * as crypto from 'crypto'; + export class SqsSchema { + readonly language: 'sqlite'; + readonly configVersion: string; + readonly sql: { + tables: SqlTables; + // Optional SQL indices + indexes: string[]; + // Further options + options: SqlOptions; + }; + constructor( - public sql: { - // SQL table definitions - tables: SqlTables; - // Optional SQL indices - indexes?: string[]; - // Further options - options?: SqlOptions; - }, - public language: 'sqlite' = 'sqlite', - ) {} + tables: SqlTables, + indexes: string[], + options: SqlOptions, + language: 'sqlite' = 'sqlite', + ) { + this.sql = { + tables: tables, + indexes: indexes, + options: options, + }; + this.language = language; + this.configVersion = this.asHash(); + } + + private asHash(): string { + return crypto + .createHash('sha256') + .update(JSON.stringify(this.sql)) + .digest('hex'); + } } type SqlTables = { diff --git a/src/query/sqs/sqs-schema-generator.service.spec.ts b/src/query/sqs/sqs-schema-generator.service.spec.ts index 2c87f89..cea79d9 100644 --- a/src/query/sqs/sqs-schema-generator.service.spec.ts +++ b/src/query/sqs/sqs-schema-generator.service.spec.ts @@ -1,122 +1,138 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { SqsSchemaService } from './sqs-schema-generator.service'; +import { + SqsSchemaGeneratorConfig, + SqsSchemaService, +} from './sqs-schema-generator.service'; +import { of, throwError } from 'rxjs'; +import { EntityConfig } from '../domain/EntityConfig'; +import { DocSuccess } from '../../couchdb/dtos'; +import spyOn = jest.spyOn; describe('SchemaGeneratorService', () => { let service: SqsSchemaService; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const entityConfig = { - 'entity:Child': { - label: 'Child', - labelPlural: 'Children', - attributes: { - address: { - dataType: 'location', - label: 'Address', - }, - health_bloodGroup: { - dataType: 'string', - label: 'Blood Group', - }, - religion: { - dataType: 'string', - label: 'Religion', - }, - motherTongue: { - dataType: 'string', - label: 'Mother Tongue', - description: 'The primary language spoken at home', - }, - health_lastDentalCheckup: { - dataType: 'date', - label: 'Last Dental Check-Up', - }, - birth_certificate: { - dataType: 'file', - label: 'Birth certificate', - }, + let mockCouchDbClient: { + changes: jest.Mock; + headDatabaseDocument: jest.Mock; + getDatabaseDocument: jest.Mock; + find: jest.Mock; + putDatabaseDocument: jest.Mock; + }; + + let mockEntityConfigResolver: { + getEntityConfig: jest.Mock; + }; + + const sqsSchemaGeneratorConfig: SqsSchemaGeneratorConfig = { + SCHEMA_PATH: '/_design/sqlite:config', + }; + + const entityConfig: EntityConfig = { + version: 'rev-1', + entities: [ + { + label: 'Child', + attributes: [ + { + name: 'name', + type: 'TEXT', + }, + { + name: 'age', + type: 'INTEGER', + }, + ], }, - }, - 'entity:School': { - attributes: { - name: { - dataType: 'string', - label: 'Name', - }, - privateSchool: { - dataType: 'boolean', - label: 'Private School', - }, - language: { - dataType: 'string', - label: 'Language', - }, - address: { - dataType: 'location', - label: 'Address', - }, - phone: { - dataType: 'string', - label: 'Phone Number', - }, - timing: { - dataType: 'string', - label: 'School Timing', - }, - remarks: { - dataType: 'string', - label: 'Remarks', - }, + { + label: 'School', + attributes: [ + { + name: 'name', + type: 'TEXT', + }, + { + name: 'type', + type: 'TEXT', + }, + { + name: 'numberOfStudents', + type: 'INTEGER', + }, + ], }, - }, - 'entity:HistoricalEntityData': { - attributes: { - isMotivatedDuringClass: { - dataType: 'configurable-enum', - additional: 'rating-answer', - label: 'Motivated', - description: 'The child is motivated during the class.', - }, - isParticipatingInClass: { - dataType: 'configurable-enum', - additional: 'rating-answer', - label: 'Participating', - description: 'The child is actively participating in the class.', - }, - isInteractingWithOthers: { - dataType: 'configurable-enum', - additional: 'rating-answer', - label: 'Interacting', - description: - 'The child interacts with other students during the class.', - }, - doesHomework: { - dataType: 'configurable-enum', - additional: 'rating-answer', - label: 'Homework', - description: 'The child does its homework.', - }, - asksQuestions: { - dataType: 'configurable-enum', - additional: 'rating-answer', - label: 'Asking Questions', - description: 'The child is asking questions during the class.', + ], + }; + + const sqsConfig = { + _id: '_design/sqlite:config', + _rev: '1-00000000', + sql: { + tables: { + Child: { + fields: { + name: 'TEXT', + age: 'INTEGER', + _id: 'TEXT', + _rev: 'TEXT', + created: 'TEXT', + updated: 'TEXT', + inactive: 'INTEGER', + anonymized: 'INTEGER', + }, + }, + School: { + fields: { + name: 'TEXT', + type: 'TEXT', + numberOfStudents: 'INTEGER', + _id: 'TEXT', + _rev: 'TEXT', + created: 'TEXT', + updated: 'TEXT', + inactive: 'INTEGER', + anonymized: 'INTEGER', + }, }, }, - }, - 'entity:User': { - attributes: { - phone: { - dataType: 'string', - label: 'Contact', + indexes: [], + options: { + table_name: { + operation: 'prefix', + field: '_id', + separator: ':', }, }, }, + language: 'sqlite', + configVersion: + '2a26f7bc7e7e69940d811a4845a5f88374cbbb9868c8f4ce13303c3be71f2ad8', }; beforeEach(async () => { + mockCouchDbClient = { + changes: jest.fn(), + headDatabaseDocument: jest.fn(), + getDatabaseDocument: jest.fn(), + find: jest.fn(), + putDatabaseDocument: jest.fn(), + }; + + mockEntityConfigResolver = { + getEntityConfig: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ - providers: [SqsSchemaService], + providers: [ + { + provide: SqsSchemaService, + useFactory: () => + new SqsSchemaService( + mockCouchDbClient, + mockEntityConfigResolver, + sqsSchemaGeneratorConfig, + ), + }, + ], }).compile(); service = module.get(SqsSchemaService); @@ -125,4 +141,97 @@ describe('SchemaGeneratorService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + it('getSchemaPath() should return Schema', () => { + expect(service.getSchemaPath()).toEqual('/_design/sqlite:config'); + }); + + it('updateSchema() should update Schema when new sqs config version is different then current', (done) => { + // given + resolveEntityConfig(); + resolveSqsConfigWithOtherVersion(); + resolveDocSuccess(); + // when + service.updateSchema().subscribe({ + next: () => { + // then + expect(mockCouchDbClient.putDatabaseDocument).toHaveBeenCalled(); + done(); + }, + error: (err) => { + done(err); + }, + }); + }); + + it('updateSchema() should update Schema when no sqs could be fetched', (done) => { + // given + resolveEntityConfig(); + resolveSqsConfigNotFound(); + resolveDocSuccess(); + // when + service.updateSchema().subscribe({ + next: () => { + // then + expect(mockCouchDbClient.putDatabaseDocument).toHaveBeenCalled(); + done(); + }, + error: (err) => { + done(err); + }, + }); + }); + + it('updateSchema() should not update Schema when entity:config is unchanged', (done) => { + // given + resolveEntityConfig(); + resolveSqsConfig(); + // when + service.updateSchema().subscribe({ + next: () => { + // then + expect(mockCouchDbClient.putDatabaseDocument).not.toHaveBeenCalled(); + done(); + }, + error: (err) => { + done(err); + }, + }); + }); + + function resolveEntityConfig() { + spyOn(mockEntityConfigResolver, 'getEntityConfig').mockReturnValue( + of(entityConfig), + ); + } + + function resolveSqsConfig() { + spyOn(mockCouchDbClient, 'getDatabaseDocument').mockReturnValue( + of(sqsConfig), + ); + } + + function resolveDocSuccess() { + spyOn(mockCouchDbClient, 'putDatabaseDocument').mockReturnValue( + of(new DocSuccess(true, 'id-123', 'r-123')), + ); + } + + function resolveSqsConfigWithOtherVersion() { + const sqsConfigWithNewVersion = { ...sqsConfig }; + sqsConfigWithNewVersion.configVersion = '123'; + spyOn(mockCouchDbClient, 'getDatabaseDocument').mockReturnValue( + of(sqsConfigWithNewVersion), + ); + } + + function resolveSqsConfigNotFound() { + const sqsConfigWithNewVersion = { ...sqsConfig }; + sqsConfigWithNewVersion.configVersion = '123'; + spyOn(mockCouchDbClient, 'getDatabaseDocument').mockReturnValue( + throwError(() => { + throw new Error('not found'); + }), + ); + } }); diff --git a/src/query/sqs/sqs-schema-generator.service.ts b/src/query/sqs/sqs-schema-generator.service.ts index ecf3bc2..275b112 100644 --- a/src/query/sqs/sqs-schema-generator.service.ts +++ b/src/query/sqs/sqs-schema-generator.service.ts @@ -1,19 +1,17 @@ -import { EMPTY, map, Observable, switchMap } from 'rxjs'; -import { CouchDbClient } from '../../couchdb/couch-db-client.service'; +import { catchError, Observable, of, switchMap, tap, zipWith } from 'rxjs'; import { IEntityConfigResolver } from '../core/entity-config-resolver.interface'; import { SqsSchema } from './dtos'; import { DocSuccess } from '../../couchdb/dtos'; import { EntityAttribute, EntityConfig } from '../domain/EntityConfig'; +import { ICouchDbClient } from '../../couchdb/couch-db-client.interface'; export class SqsSchemaGeneratorConfig { SCHEMA_PATH = ''; } export class SqsSchemaService { - private _entityConfigVersion = ''; - constructor( - private couchDbClient: CouchDbClient, + private couchDbClient: ICouchDbClient, private entityConfigResolver: IEntityConfigResolver, private config: SqsSchemaGeneratorConfig, ) {} @@ -23,32 +21,57 @@ export class SqsSchemaService { } /** - * Loads EntityConfig and updates SqsSchema if necessary + * Loads EntityConfig and current SQS Schema. Updates SqsSchema if necessary */ updateSchema(): Observable { return this.entityConfigResolver.getEntityConfig().pipe( - switchMap((entityConfig) => { - if (entityConfig.version === this._entityConfigVersion) { - return EMPTY; - } else { - return this.couchDbClient - .putDatabaseDocument({ - documentId: this.config.SCHEMA_PATH, - body: this.mapToSqsSchema(entityConfig), - config: {}, - }) - .pipe( - map((result) => { - this._entityConfigVersion = result.rev; - }), - ); + zipWith( + this.couchDbClient + .getDatabaseDocument({ + documentId: this.config.SCHEMA_PATH, + }) + .pipe( + catchError(() => { + console.debug( + '[SqsSchemaService] No active sqs schema found in db.', + ); + return of(undefined); + }), + ), + ), + switchMap((result) => { + const entityConfig = result[0]; + const currentSqsSchema = result[1]; + const newSqsSchema = this.mapToSqsSchema(entityConfig); + + if (currentSqsSchema?.configVersion === newSqsSchema.configVersion) { + console.debug( + '[SqsSchemaService] sqs schema is up to date. not updated.', + ); + return of(undefined); } + + return this.couchDbClient + .putDatabaseDocument({ + documentId: this.config.SCHEMA_PATH, + body: newSqsSchema, + config: {}, + }) + .pipe( + tap((result) => { + console.debug( + '[SqsSchemaService] sqs schema updated to latest version', + result, + ); + }), + switchMap(() => of(undefined)), + ); }), ); } private mapToSqsSchema(entityConfig: EntityConfig): SqsSchema { - const sqsSchema = new SqsSchema({ + const sqsSchema: any = { tables: {}, indexes: [], options: { @@ -58,7 +81,7 @@ export class SqsSchemaService { separator: ':', }, }, - }); + }; entityConfig.entities.forEach((entityConfig) => { const fields: { @@ -77,12 +100,16 @@ export class SqsSchemaService { } }); - sqsSchema.sql.tables[entityConfig.label] = { + sqsSchema.tables[entityConfig.label] = { fields: fields, }; }); - return sqsSchema; + return new SqsSchema( + sqsSchema.tables, + sqsSchema.indexes, + sqsSchema.options, + ); } private getDefaultEntityAttributes(): EntityAttribute[] {