diff --git a/src/couchdb/couch-db-client.service.ts b/src/couchdb/couch-db-client.service.ts index 1bb1483..4833ff8 100644 --- a/src/couchdb/couch-db-client.service.ts +++ b/src/couchdb/couch-db-client.service.ts @@ -4,7 +4,7 @@ import { HttpService } from '@nestjs/axios'; import { AxiosHeaders } from 'axios'; import { CouchDbChangesResponse } from './dtos'; -@Injectable({}) +@Injectable() export class CouchDbClient { private readonly logger = new Logger(CouchDbClient.name); diff --git a/src/report-changes/core/couchdb-report-changes.service.spec.ts b/src/report-changes/core/couchdb-report-changes.service.spec.ts deleted file mode 100644 index 1901157..0000000 --- a/src/report-changes/core/couchdb-report-changes.service.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { CouchdbReportChangesService } from './couchdb-report-changes.service'; -import { BehaviorSubject, map } from 'rxjs'; -import { NotificationService } from '../../notification/core/notification.service'; -import { Reference } from '../../domain/reference'; - -describe('CouchdbReportChangesService', () => { - let service: CouchdbReportChangesService; - let mockNotificationService: Partial; - - let activeReports: BehaviorSubject; - - beforeEach(async () => { - activeReports = new BehaviorSubject([]); - mockNotificationService = { - activeReports: () => - activeReports - .asObservable() - .pipe(map((reportIds) => reportIds.map((id) => new Reference(id)))), - triggerNotification: jest.fn(), - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - CouchdbReportChangesService, - { provide: NotificationService, useValue: mockNotificationService }, - ], - }).compile(); - - service = module.get( - CouchdbReportChangesService, - ); - }); - - it('should trigger core after adding active report through NotificationService', (done) => { - const testReportId = 'report1'; - activeReports.next([testReportId]); - - // TODO mock a couchDbService.changes event - - ( - mockNotificationService.triggerNotification as jest.Mock - ).mockImplementation((reportId: string) => { - expect(reportId).toBe(testReportId); - done(); - }); - }); - - it('should trigger core after adding active report through NotificationService', async () => { - activeReports.next(['report1']); - activeReports.next(['report2' /* removed report1 */]); - - // TODO mock a couchDbService.changes event - - await new Promise(process.nextTick); // wait for any async operations to finish - expect( - mockNotificationService.triggerNotification, - ).not.toHaveBeenCalledWith('report1'); - expect(mockNotificationService.triggerNotification).toHaveBeenCalledWith( - 'report2', - ); - }); -}); diff --git a/src/report-changes/core/couchdb-report-changes.service.ts b/src/report-changes/core/couchdb-report-changes.service.ts deleted file mode 100644 index 35f280f..0000000 --- a/src/report-changes/core/couchdb-report-changes.service.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { EntityDoc, ReportChangeDetector } from './report-change.detector'; -import { NotificationService } from '../../notification/core/notification.service'; -import { Reference } from '../../domain/reference'; -import { ReportDataChangeEvent } from '../../domain/report-data-change-event'; -import { ReportCalculation } from '../../domain/report-calculation'; -import { - CouchDbChangeResult, - CouchDbChangesResponse, -} from '../../couchdb/dtos'; -import { Report } from '../../domain/report'; -import { ReportChangesService } from './report-changes.service'; -import { CouchdbChangesService } from '../storage/couchdb-changes.service'; -import { DefaultReportStorage } from '../../report/storage/report-storage.service'; -import { map, mergeAll, tap } from 'rxjs'; - -@Injectable() -export class CouchdbReportChangesService implements ReportChangesService { - private reportMonitors = new Map(); - - constructor( - private notificationService: NotificationService, - private reportStorage: DefaultReportStorage, - private couchdbChangesRepository: CouchdbChangesService, - ) { - this.notificationService - .activeReports() - .subscribe((reports: Reference[]) => - reports.forEach((r) => this.registerReportMonitoring(r)), - ); - - this.monitorCouchDbChanges(); - } - - async registerReportMonitoring(report: Reference) { - if (!this.reportMonitors.has(report.id)) { - this.setReportMonitor(report); - } - } - - private setReportMonitor(report: Reference) { - this.reportStorage - .fetchReport(report) - .subscribe((report: Report | undefined) => { - if (!report) { - return; - } - - this.reportMonitors.set(report.id, new ReportChangeDetector(report)); - }); - } - - private checkReportConfigUpdate(change: CouchDbChangeResult) { - if (this.reportMonitors.has(change.id)) { - this.setReportMonitor(new Reference(change.id)); - return; - } - - // TODO: reportId should in future be without prefix, probably? - // (then remove to fallback code above) - const id = change.id.split(':'); - if ( - id.length === 2 && - id[0] === 'ReportConfig' && - this.reportMonitors.has(id[1]) - ) { - this.setReportMonitor(new Reference(change.id)); - } - } - - monitorCouchDbChanges() { - this.couchdbChangesRepository - .subscribeToAllNewChanges() - .pipe( - map((changes: CouchDbChangesResponse) => changes.results), - mergeAll(), - tap((change: CouchDbChangeResult) => - this.checkReportConfigUpdate(change), - ), - map((c: CouchDbChangeResult) => this.getChangeDetails(c)), - map((change: DocChangeDetails) => this.changeIsAffectingReport(change)), - // TODO: collect a batch of changes for a while before checking? - ) - .subscribe((affectedReports: ReportDataChangeEvent[]) => { - affectedReports.forEach((event) => { - this.notificationService.triggerNotification(event), - console.log('Report change detected:', event); - }); - }); - } - - /** - * Load current and previous doc for advanced change detection across all reports. - * @param change - * @private - */ - private getChangeDetails(change: CouchDbChangeResult): DocChangeDetails { - // TODO: storage to get any doc from DB (for a _rev also!) - // until then, only the .change with the id can be used in ReportChangeDetector - // can also use ?include_docs=true in the changes request to get the latest doc - - return { - change: change, - previous: { _id: '' }, // cache this here to avoid requests? - new: { _id: '' }, - }; - } - - private changeIsAffectingReport( - docChange: DocChangeDetails, - ): ReportDataChangeEvent[] { - const affectedReports = []; - - for (const [reportId, changeDetector] of this.reportMonitors.entries()) { - if (!changeDetector.affectsReport(docChange)) { - continue; - } - - const reportRef = new Reference(reportId); - - // (!!!) TODO: calculate a new report calculation here (or in ChangeDetector?) - // --> move ReportCalculationController implementation into a core service with .triggerCalculation(reportId) method - // const newResult = await this.reportService.runReportCalculation(reportId); - // if (newResult.hash !== oldResult.hash) - const calculation: ReportCalculation = new ReportCalculation( - 'x', - reportRef, - ); - - const event: ReportDataChangeEvent = { - report: reportRef, - calculation: reportRef, - }; - - affectedReports.push(event); - } - - return affectedReports; - } -} - -export interface DocChangeDetails { - change: CouchDbChangeResult; - previous: EntityDoc; - new: EntityDoc; -} diff --git a/src/report-changes/core/report-change.detector.ts b/src/report-changes/core/report-change.detector.ts deleted file mode 100644 index b920eda..0000000 --- a/src/report-changes/core/report-change.detector.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Report } from '../../domain/report'; -import { DocChangeDetails } from './couchdb-report-changes.service'; - -export class ReportChangeDetector { - private report?: Report; - private sqlTableNames: string[] = []; - - constructor(report: Report) { - this.updateReportConfig(report); - } - - updateReportConfig(report: Report) { - this.report = report; - - this.sqlTableNames = this.getSqlTableNames(report); - } - - private getSqlTableNames(report: Report) { - const sqlFromTableRegex = /FROM\s+(\w+)/g; - - return report.queries - .map((sql: string) => - [...sql.matchAll(sqlFromTableRegex)].map( - (match) => match[1] /* matching regex group (table name) */, - ), - ) - .flat(); - } - - affectsReport(doc: DocChangeDetails): boolean { - const entityType = doc.change.id.split(':')[0]; - if (!this.sqlTableNames.includes(entityType)) { - return false; - } - - // TODO: better detection if doc affects report - return true; - } -} - -/** - * A doc in the database representing an entity managed in the frontend. - */ -export interface EntityDoc { - _id: string; - - [key: string]: any; -} diff --git a/src/report/core/sqs-report-calculator.service.spec.ts b/src/report/core/sqs-report-calculator.service.spec.ts index 5bcaf9f..59147a6 100644 --- a/src/report/core/sqs-report-calculator.service.spec.ts +++ b/src/report/core/sqs-report-calculator.service.spec.ts @@ -1,12 +1,21 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SqsReportCalculator } from './sqs-report-calculator.service'; +import { DefaultReportStorage } from '../storage/report-storage.service'; +import { CouchSqsClient } from '../../couchdb/couch-sqs.client'; describe('SqsReportCalculatorService', () => { let service: SqsReportCalculator; + let mockCouchSqsClient: { executeQuery: jest.Mock }; + let mockReportStorage: { fetchAllReports: jest.Mock }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [SqsReportCalculator], + providers: [ + SqsReportCalculator, + { provide: CouchSqsClient, useValue: mockCouchSqsClient }, + { provide: DefaultReportStorage, useValue: mockReportStorage }, + ], }).compile(); service = module.get(SqsReportCalculator); diff --git a/src/report/core/sqs-report-calculator.service.ts b/src/report/core/sqs-report-calculator.service.ts index b2bcfe7..896bf36 100644 --- a/src/report/core/sqs-report-calculator.service.ts +++ b/src/report/core/sqs-report-calculator.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable, + InternalServerErrorException, NotFoundException, } from '@nestjs/common'; import { ReportCalculator } from './report-calculator'; @@ -31,7 +32,7 @@ export class SqsReportCalculator implements ReportCalculator { } if (report.queries.length === 0) { - throw new BadRequestException(); + throw new InternalServerErrorException(); } return report.queries.flatMap((query) => { diff --git a/src/report/tasks/report-calculation-processor.service.spec.ts b/src/report/tasks/report-calculation-processor.service.spec.ts index 12efd8c..413b87a 100644 --- a/src/report/tasks/report-calculation-processor.service.spec.ts +++ b/src/report/tasks/report-calculation-processor.service.spec.ts @@ -1,36 +1,21 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ReportCalculationProcessor } from './report-calculation-processor.service'; import { DefaultReportStorage } from '../storage/report-storage.service'; -import { HttpModule } from '@nestjs/axios'; -import { CouchDbClient } from '../../couchdb/couch-db-client.service'; -import { ReportCalculationTask } from './report-calculation-task.service'; import { SqsReportCalculator } from '../core/sqs-report-calculator.service'; -import { ReportRepository } from '../repository/report-repository.service'; -import { ReportCalculationRepository } from '../repository/report-calculation-repository.service'; -import { ConfigService } from '@nestjs/config'; describe('ReportCalculationProcessorService', () => { let service: ReportCalculationProcessor; + let mockReportStorage: { fetchAllReports: jest.Mock }; + let mockSqsReportCalculator: { calculate: jest.Mock }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - imports: [HttpModule], + imports: [], providers: [ - CouchDbClient, - ReportCalculationTask, - DefaultReportStorage, ReportCalculationProcessor, - SqsReportCalculator, - ReportRepository, - ReportCalculationRepository, - { - provide: ConfigService, - useValue: { - getOrThrow: jest.fn((key) => { - return 'foo'; - }), - }, - }, + { provide: DefaultReportStorage, useValue: mockReportStorage }, + { provide: SqsReportCalculator, useValue: mockSqsReportCalculator }, ], }).compile(); diff --git a/src/report/tasks/report-calculation-task.service.spec.ts b/src/report/tasks/report-calculation-task.service.spec.ts index 5a92b87..ccbbbd1 100644 --- a/src/report/tasks/report-calculation-task.service.spec.ts +++ b/src/report/tasks/report-calculation-task.service.spec.ts @@ -1,35 +1,26 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ReportCalculationTask } from './report-calculation-task.service'; import { ReportCalculationProcessor } from './report-calculation-processor.service'; -import { SqsReportCalculator } from '../core/sqs-report-calculator.service'; -import { DefaultReportStorage } from '../storage/report-storage.service'; -import { ConfigService } from '@nestjs/config'; -import { ReportRepository } from '../repository/report-repository.service'; -import { ReportCalculationRepository } from '../repository/report-calculation-repository.service'; -import { HttpModule } from '@nestjs/axios'; -import { CouchDbClient } from '../../couchdb/couch-db-client.service'; describe('ReportCalculationTaskService', () => { let service: ReportCalculationTask; + let mockReportCalculationProcessor: { + processNextPendingCalculation: jest.Mock; + }; + beforeEach(async () => { + mockReportCalculationProcessor = { + processNextPendingCalculation: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ - imports: [HttpModule], + imports: [], providers: [ - CouchDbClient, ReportCalculationTask, - DefaultReportStorage, - ReportCalculationProcessor, - SqsReportCalculator, - ReportRepository, - ReportCalculationRepository, { - provide: ConfigService, - useValue: { - getOrThrow: jest.fn((key) => { - return 'foo'; - }), - }, + provide: ReportCalculationProcessor, + useValue: mockReportCalculationProcessor, }, ], }).compile();