04 - Facade

Facade

Facade è uno dei design pattern più semplici e generici, consiste nel creare un'interfaccia semplificata a un problema complesso che richiede l'utilizzo di più "componenti".

Esempio

Abbiamo detto che abbiamo
Inputs:

  • userId
  • record

Outputs:

  • risultato nel formato TimeEntryResultDTO

result-calculator.service.ts

import { Injectable } from "@nestjs/common";
import { TimeEntryResultFactory } from "./time-entry.result.factory";
import { TimeEntry } from "./time-entry.schema";
import { TimeEntryResultDTO } from "./time-entry.dto";
import { DurationSettingsDataSource } from "../duration-settings/duration-settings.ds";
import { DurationStrategySelectorService } from "../duration/duration-strategy-selector.service";
import { TimeEntryDurationService } from "../duration/duration.service";
import { TimeEntryAmountService } from "../amount/amount.service";
import { AmountSettingsDataSource } from "../amount-settings/amount-settings.ds";
import { FixedAmountService } from "../amount/fixed-amount.service";

@Injectable()
export class TimeEntryResultCalculator {
  constructor(
    protected readonly resultFactorySrv: TimeEntryResultFactory,
    protected readonly durationSettingsSrv: DurationSettingsDataSource,
    protected readonly durationStrategySelector: DurationStrategySelectorService,
    protected readonly amountProvider: AmountServiceProvider) {}

  protected async getDurationService(userId: string): Promise<TimeEntryDurationService> {
    const durationSettings = await this.durationSettingsSrv.getDurationSettings(userId);
    return this.durationStrategySelector.getStrategy(durationSettings.strategy);
  }

  protected async getAmountService(userId: string, durationSrv: TimeEntryDurationService, item: TimeEntry): Promise<TimeEntryAmountService> {
    let amountSrv = await this.amountProvider.getAmountService(userId);
    const duration = durationSrv.getDuration(item.start, item.end);
    if (duration < some_param) {
      amountSrv = new FixedAmountService(0);
    }
    return amountSrv;
  }

  async calcResult(userId: string, item: TimeEntry): Promise<TimeEntryResultDTO> {
    const durationSrv = await this.getDurationService(userId);
    const amountSrv = await this.getAmountService(userId, durationSrv, item);

    const resultFactory = this.resultFactorySrv.getFactory(durationSrv, amountSrv);
    return resultFactory(item);
  }
}

Andiamo a modificare i settings dell'amount perché tornino anche il parametro necessario:
amount-settings.entity.ts

export interface HourlyRateSettings {
	hourlyRate: number;
}

export interface MinBillableSettings {
	minDuration: number;
}

export interface AmountSettings extends HourlyRateSettings, MinBillableSettings {}

amount-settings.ds.ts

import { Injectable } from "@nestjs/common";
import { AmountSettings } from "./amount-settings.entity";

@Injectable()
export abstract class AmountSettingsDataSource {
  abstract getAmountSettings(userId: string): Promise<AmountSettings>;
}

amount-settings.ds.static.ts

import { Inject, Optional } from "@nestjs/common";
import { AmountSettingsDataSource } from "./amount-settings.ds";
import { AmountSettings } from "./amount-settings.entity";

export const STATIC_HOURLY_RATE = 'STATIC_HOURLY_RATE';
export const STATIC_MIN_BILLABLE_TIME = 'STATIC_MIN_BILLABLE_TIME';

export class AmountSettingsStatiDataSource extends AmountSettingsDataSource {
  constructor(
    @Optional() @Inject(STATIC_HOURLY_RATE) protected hourlyRate: number = 60,
    @Optional() @Inject(STATIC_MIN_BILLABLE_TIME) protected minBillable: number = 10
  ) {
    super();
  }

  async getAmountSettings(userId: string): Promise<AmountSettings> {
    return {
      hourlyRate: this.hourlyRate,
      minDuration: this.minBillable
    }  
  }

}

A questo punto il calculator service diventa:

import { Injectable } from "@nestjs/common";
import { TimeEntryResultFactory } from "./time-entry.result.factory";
import { TimeEntry } from "./time-entry.schema";
import { TimeEntryResultDTO } from "./time-entry.dto";
import { DurationSettingsDataSource } from "../duration-settings/duration-settings.ds";
import { DurationStrategySelectorService } from "../duration/duration-strategy-selector.service";
import { TimeEntryDurationService } from "../duration/duration.service";
import { TimeEntryAmountService } from "../amount/amount.service";
import { AmountSettingsDataSource } from "../amount-settings/amount-settings.ds";
import { FixedAmountService } from "../amount/fixed-amount.service";

@Injectable()
export class TimeEntryResultCalculator {
  constructor(
    protected readonly resultFactorySrv: TimeEntryResultFactory,
    protected readonly durationSettingsSrv: DurationSettingsDataSource,
    protected readonly durationStrategySelector: DurationStrategySelectorService,
    protected readonly amountSettingsSrv: AmountSettingsDataSource,
    protected readonly amountProvider: AmountServiceProvider) {}

  protected async getDurationService(userId: string): Promise<TimeEntryDurationService> {
    const durationSettings = await this.durationSettingsSrv.getDurationSettings(userId);
    return this.durationStrategySelector.getStrategy(durationSettings.strategy);
  }

  protected async getAmountService(userId: string, durationSrv: TimeEntryDurationService, item: TimeEntry): Promise<TimeEntryAmountService> {
    const amountSettings = await this.amountSettingsSrv.getAmountSettings(userId);
    let amountSrv = await this.amountProvider.getAmountService(userId);
    const duration = durationSrv.getDuration(item.start, item.end);
    if (duration < amountSettings.minDuration) {
      amountSrv = new FixedAmountService(0);
    }
    return amountSrv;
  }

  async calcResult(userId: string, item: TimeEntry): Promise<TimeEntryResultDTO> {
    const durationSrv = await this.getDurationService(userId);
    const amountSrv = await this.getAmountService(userId, durationSrv, item);

    const resultFactory = this.resultFactorySrv.getFactory(durationSrv, amountSrv);
    return resultFactory(item);
  }
}

Vediamo che probabilmente a questo punto l'amountProvider non ci serve più, primo non è più vero che basta lo userId per scegliere quale amountSrv andare a usare, e secondo dobbiamo comunque andare a prendere i settings per controllare la durata.
Abbiamo comunque tutti i "blocchetti che ci servono", spostiamo la logica direttamente nel ResultCalculator.

import { Injectable } from "@nestjs/common";
import { TimeEntryResultFactory } from "./time-entry.result.factory";
import { TimeEntry } from "./time-entry.schema";
import { TimeEntryResultDTO } from "./time-entry.dto";
import { DurationSettingsDataSource } from "../duration-settings/duration-settings.ds";
import { DurationStrategySelectorService } from "../duration/duration-strategy-selector.service";
import { TimeEntryDurationService } from "../duration/duration.service";
import { TimeEntryAmountService } from "../amount/amount.service";
import { AmountSettingsDataSource } from "../amount-settings/amount-settings.ds";
import { FixedAmountService } from "../amount/fixed-amount.service";

@Injectable()
export class TimeEntryResultCalculator {
  constructor(
    protected readonly resultFactorySrv: TimeEntryResultFactory,
    protected readonly durationSettingsSrv: DurationSettingsDataSource,
    protected readonly durationStrategySelector: DurationStrategySelectorService,
    protected readonly amountSettingsSrv: AmountSettingsDataSource) {}

  protected async getDurationService(userId: string): Promise<TimeEntryDurationService> {
    const durationSettings = await this.durationSettingsSrv.getDurationSettings(userId);
    return this.durationStrategySelector.getStrategy(durationSettings.strategy);
  }

  protected async getAmountService(userId: string, durationSrv: TimeEntryDurationService, item: TimeEntry): Promise<TimeEntryAmountService> {
    const amountSettings = await this.amountSettingsSrv.getAmountSettings(userId);
    const duration = durationSrv.getDuration(item.start, item.end);
    if (duration < amountSettings.minDuration) {
      return new FixedAmountService(0);
    }
    return new FixedAmountService(amountSettings.hourlyRate);
  }

  async calcResult(userId: string, item: TimeEntry): Promise<TimeEntryResultDTO> {
    const durationSrv = await this.getDurationService(userId);
    const amountSrv = await this.getAmountService(userId, durationSrv, item);

    const resultFactory = this.resultFactorySrv.getFactory(durationSrv, amountSrv);
    return resultFactory(item);
  }
}

Andiamo a testarlo:

result-calculator.service.spec.ts

import { Types } from "mongoose";
import { AmountSettingsDataSource } from "../amount-settings/amount-settings.ds";
import { AmountSettingsStatiDataSource } from "../amount-settings/amount-settings.ds.static";
import { DurationSettingsDataSource } from "../duration-settings/duration-settings.ds";
import { DurationSettingsStaticDataSource } from "../duration-settings/duration-settings.ds.static.service";
import { DurationStrategySelectorService } from "../duration/duration-strategy-selector.service";
import { ExactTimeEntryDurationService } from "../duration/exact-duration.service";
import { TimeEntryResultCalculator } from "./result-calculator.service";
import { TimeEntryResultFactory } from "./time-entry.result.factory";

describe('TimeEntryResultCalculator', () => {
  const resultFactorySrv: TimeEntryResultFactory = new TimeEntryResultFactory();
  let durationSettingsSrv: DurationSettingsDataSource;
  let durationStrategySelector: DurationStrategySelectorService;
  let amountSettingsSrv: AmountSettingsDataSource;
  let resultCalculator: TimeEntryResultCalculator;

  beforeEach(() => {
    durationSettingsSrv = new DurationSettingsStaticDataSource('exact');
    durationStrategySelector = new DurationStrategySelectorService();
    durationStrategySelector.addStrategy('exact', new ExactTimeEntryDurationService());
    amountSettingsSrv = new AmountSettingsStatiDataSource(60);
    resultCalculator = new TimeEntryResultCalculator(resultFactorySrv, durationSettingsSrv, durationStrategySelector, amountSettingsSrv);
  })

  it('should return a record with amount 60', async () => {
    const record = {
      id: new Types.ObjectId().toString(),
      description: 'Test1',
      start: new Date('2024-01-10T10:00:00.000Z'),
      end: new Date('2024-01-10T11:00:00.000Z'),
      billable: true
    }
    const result = await resultCalculator.calcResult('test', record);
    expect(result.amount).toBe(60);
  })

  it('should return a record with amount 30', async () => {
    const record = {
      id: new Types.ObjectId().toString(),
      description: 'Test1',
      start: new Date('2024-01-10T10:00:00.000Z'),
      end: new Date('2024-01-10T10:30:00.000Z'),
      billable: true
    }
    const result = await resultCalculator.calcResult('test', record);
    expect(result.amount).toBe(30);
  })

  it('should return a record with amount 0', async () => {
    const record = {
      id: new Types.ObjectId().toString(),
      description: 'Test1',
      start: new Date('2024-01-10T10:00:00.000Z'),
      end: new Date('2024-01-10T10:00:00.000Z'),
      billable: true
    }
    const result = await resultCalculator.calcResult('test', record);
    expect(result.amount).toBe(0);
  })

  it('should consider min billable', async () => {
    const record = {
      id: new Types.ObjectId().toString(),
      description: 'Test1',
      start: new Date('2024-01-10T10:00:00.000Z'),
      end: new Date('2024-01-10T10:05:00.000Z'),
      billable: true
    }
    const result = await resultCalculator.calcResult('test', record);
    expect(result.amount).toBe(0);
  })
})

A quanto pare c'è un problema: l'impostazione è in minuti ma getDuration torna ore.
Andiamo a modificare il duration service per renderlo un po' più flessibile:

@Injectable()
export abstract class TimeEntryDurationService {
  /**
   * Return the duration in millisecond, transformed by the business logic.
   *
   * @param millis - The original number of milliseconds
   * @returns The transformed number of milliseconds
   *
   * @beta
   */
  protected abstract calcDuration(millis: number): number;

  getMillis(start: Date, end: Date): number {
    const millis = end.getTime() - start.getTime();
    return this.calcDuration(millis);
  }

  getMinutes(start: Date, end: Date): number {
    return this.getMillis(start, end) / (1000 * 60);
  }

  getHours(start: Date, end: Date): number {
    return this.getMinutes(start, end) / 60;
  }

  getDuration(start: Date, end: Date): number {
    return this.getHours(start, end);
  }
}

export class ExactTimeEntryDurationService extends TimeEntryDurationService {
  protected calcDuration(millis: number): number {
    return millis;
  }
}

export class RoundedDurationService extends TimeEntryDurationService {
  constructor(protected roundValue: number = 30) {
    super();
  }
  
  protected calcDuration(millis: number): number {
    const roundMillis = this.roundValue * 1000 * 60;
    return Math.round(millis / roundMillis) * roundMillis;
  }
} 

Come possiamo vedere abbiamo modificato la logica della nostra classe andando solo ad aggiungere metodi, senza cambiare quello esistente, questo ci garantisce che il resto del codice rimanga compatibile.

Questo ci da a disposizione il metodo getMinutes per fare il confronto in ResultCalculator, che diventa:

import { Injectable } from "@nestjs/common";
import { TimeEntryResultFactory } from "./time-entry.result.factory";
import { TimeEntry } from "./time-entry.schema";
import { TimeEntryResultDTO } from "./time-entry.dto";
import { DurationSettingsDataSource } from "../duration-settings/duration-settings.ds";
import { DurationStrategySelectorService } from "../duration/duration-strategy-selector.service";
import { TimeEntryDurationService } from "../duration/duration.service";
import { TimeEntryAmountService } from "../amount/amount.service";
import { AmountSettingsDataSource } from "../amount-settings/amount-settings.ds";
import { FixedAmountService } from "../amount/fixed-amount.service";

@Injectable()
export class TimeEntryResultCalculator {
  constructor(
    protected readonly resultFactorySrv: TimeEntryResultFactory,
    protected readonly durationSettingsSrv: DurationSettingsDataSource,
    protected readonly durationStrategySelector: DurationStrategySelectorService,
    protected readonly amountSettingsSrv: AmountSettingsDataSource) {}

  protected async getDurationService(userId: string): Promise<TimeEntryDurationService> {
    const durationSettings = await this.durationSettingsSrv.getDurationSettings(userId);
    return this.durationStrategySelector.getStrategy(durationSettings.strategy);
  }

  protected async getAmountService(userId: string, durationSrv: TimeEntryDurationService, item: TimeEntry): Promise<TimeEntryAmountService> {
    const amountSettings = await this.amountSettingsSrv.getAmountSettings(userId);
    const duration = durationSrv.getMinutes(item.start, item.end);
    if (duration < amountSettings.minDuration) {
      return new FixedAmountService(0);
    }
    return new FixedAmountService(amountSettings.hourlyRate);
  }

  async calcResult(userId: string, item: TimeEntry): Promise<TimeEntryResultDTO> {
    const durationSrv = await this.getDurationService(userId);
    const amountSrv = await this.getAmountService(userId, durationSrv, item);

    const resultFactory = this.resultFactorySrv.getFactory(durationSrv, amountSrv);
    return resultFactory(item);
  }
}

Il controller a questo punto diventa:
time-entry.controller.ts

import {
  Body,
  Controller,
  Get,
  HttpException,
  HttpStatus,
  Param,
  Post,
  UsePipes,
  ValidationPipe,
} from '@nestjs/common';
import { CreateTimeEntryDTO, TimeEntryResultDTO } from './entities/time-entry.dto';
import { TimeEntryDataSource } from './datasource/datasource.service';
import { TimeEntryResultCalculator } from './entities/result-calculator.service';

const FAKE_USERID = 'test';

@Controller('time-entries')
export class TimeEntryController {
  constructor(
    protected readonly dataSource: TimeEntryDataSource,
    protected readonly resultCalculator: TimeEntryResultCalculator
  ) {}

  @Get()
  async list(): Promise<TimeEntryResultDTO[]> {
    const list = await this.dataSource.list();

    const promises = list.map((e) => {
      return this.resultCalculator.calcResult(FAKE_USERID, e);
    });
    return Promise.all(promises);
  }

  @Get(':id')
  async detail(@Param('id') id: string): Promise<TimeEntryResultDTO> {
    const record = await this.dataSource.get(id);
    if (!record) {
      throw new HttpException('Not found', HttpStatus.NOT_FOUND);
    }
    
    return this.resultCalculator.calcResult(FAKE_USERID, record);
  }

  @Post()
  @UsePipes(new ValidationPipe({transform: true}))
  async create(@Body() createTimeEntryDTO: CreateTimeEntryDTO): Promise<TimeEntryResultDTO> {
    const record = await this.dataSource.create(createTimeEntryDTO);
    
    return this.resultCalculator.calcResult(FAKE_USERID, record);
  }
}

E i sui test:
time-entry.controller.spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { TimeEntryController } from './time-entry.controller';
import { TimeEntryDataSource } from './datasource/datasource.service';
import { TimeEntryMockDataSource } from './mock/time-entry.ds.mock.service';
import { Types } from 'mongoose';
import { TimeEntry } from './entities/time-entry.schema';
import { FixedAmountService } from './amount/fixed-amount.service';
import { TimeEntryAmountService } from './amount/amount.service';
import { TimeEntryResultFactory } from './entities/time-entry.result.factory';
import { DurationSettingsDataSource } from './duration-settings/duration-settings.ds';
import { DurationStrategySelectorService } from './duration/duration-strategy-selector.service';
import { ExactTimeEntryDurationService } from './duration/exact-duration.service';
import { DurationSettingsStaticDataSource, STATIC_DURATION_STRATEGY } from './duration-settings/duration-settings.ds.static.service';
import { TimeEntryResultCalculator } from './entities/result-calculator.service';

describe('TimeEntryController', () => {
  let controller: TimeEntryController;
  let dataSource: TimeEntryMockDataSource;
  let spyResult: jest.Mock;
  

  beforeEach(async () => {
    dataSource = new TimeEntryMockDataSource();
    spyResult = jest.fn().mockResolvedValue({});
    const app: TestingModule = await Test.createTestingModule({
      controllers: [TimeEntryController],
      providers: [{
        provide: TimeEntryDataSource,
        useValue: dataSource
      },
      {provide: TimeEntryAmountService, useClass: FixedAmountService},
      {provide: TimeEntryResultCalculator, useValue: {calcResult: spyResult} }
    ],
    }).compile();

    controller = app.get<TimeEntryController>(TimeEntryController);
  });

  describe('result calculator', () => {
    const records: TimeEntry[] = [
      {
        id: new Types.ObjectId().toString(),
        description: 'Test1',
        start: new Date(),
        end: new Date(),
        billable: true
      },
      {
        id: new Types.ObjectId().toString(),
        description: 'Test2',
        start: new Date(),
        end: new Date(),
        billable: true
      }
    ];
    beforeEach(() => {
      dataSource.setRecords(records);
    })

    it('LIST: should call the settings provider', async () => {
		await controller.list();
		for(let i = 0; i < records.length; i++) {
		  expect(spyResult).toHaveBeenNthCalledWith(i+1, 'test', records[i]);
		}
    })
    it('DETAIL: should call the settings provider', async () => {
        await controller.detail(records[0].id.toString());
        expect(spyResult).toHaveBeenCalledWith('test', records[0]);
    })
    it('CREATE: should calculate result', async () => {
        const record = {
          description: 'Test1',
          start: new Date('2024-01-10T10:00:00.000Z'),
          end: new Date('2024-01-10T11:00:00.000Z'),
          billable: true
        }
        await controller.create(record);
        expect(spyResult).toHaveBeenCalled();
    })
  })

  describe('list',  () => {
    it('should return a list of elements"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId().toString(),
          description: 'Test1',
          start: new Date(),
          end: new Date(),
          billable: true
        },
        {
          id: new Types.ObjectId().toString(),
          description: 'Test2',
          start: new Date(),
          end: new Date(),
          billable: true
        }
      ];
      dataSource.setRecords(records);
      return controller.list().then(result => {
        expect(result.length).toBe(records.length);
      })
    });
  });

  describe('detail', () => {
    it('should return a single record"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId().toString(),
          description: 'Test1',
          start: new Date(),
          end: new Date(),
          billable: true
        },
        {
          id: new Types.ObjectId().toString(),
          description: 'Test2',
          start: new Date(),
          end: new Date(),
          billable: true
        }
      ];
      dataSource.setRecords(records);
      return controller.detail(records[1].id.toString()).then(result => {
        expect(result).toStrictEqual({});
      })
    });

    it('should throw an exception if not found"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId().toString(),
          description: 'Test1',
          start: new Date('2024-01-10T10:00:00.000Z'),
          end: new Date('2024-01-10T11:00:00.000Z'),
          billable: false
        }
      ];
      dataSource.setRecords(records);
      return expect(controller.detail('test')).rejects.toThrow('Not found');
    });

  });

  describe('create', () => {
    it('should add a new record', async () => {
      const record = {
        description: 'Test1',
        start: new Date('2024-01-10T10:00:00.000Z'),
        end: new Date('2024-01-10T11:00:00.000Z'),
        billable: true
      }
      return controller.create(record).then(result =>{
        expect(result).toStrictEqual({});
      })
    });
  })
});

Vantaggi e quando usarla

Vale la pena creare una facade quando l'interfaccia che espone è abbastanza limitata da agevolare realmente l'utilizzo del sistema, se ad esempio richiede troppi input o variabili diverse probabilmente non vale la pena o bisogna ristrutturare ulteriormente il programma.

Vantaggi:

  • sistemi complessi risultano più semplici da utilizzare
  • il client che utilizza la facade è all'oscuro dell'implementazione che ci sta dietro, si può cambiare la logica quanto si vuole purché non si modifichi l'interfaccia

Abbiamo un nuovo problema, da risolvere, dovendo calcolare i servizi per ogni elemento stiamo facendo delle chiamate asincrone per ogni item dell'array che dobbiamo tornare, senza considerare che le impostazioni potrebbero essere le stesse.
Mettiamo dei console.log per verificare il comportamento.

Proviamo intanto a risolvere facendo in modo che il ResultCalculator prenda in carico direttamente la lista degli elementi in modo da fare la chiamata una volta sola.

import { Injectable } from "@nestjs/common";
import { TimeEntryResultFactory } from "./time-entry.result.factory";
import { TimeEntry } from "./time-entry.schema";
import { TimeEntryResultDTO } from "./time-entry.dto";
import { DurationSettingsDataSource } from "../duration-settings/duration-settings.ds";
import { DurationStrategySelectorService } from "../duration/duration-strategy-selector.service";
import { TimeEntryDurationService } from "../duration/duration.service";
import { TimeEntryAmountService } from "../amount/amount.service";
import { AmountSettingsDataSource } from "../amount-settings/amount-settings.ds";
import { FixedAmountService } from "../amount/fixed-amount.service";
import { AmountSettings } from "../amount-settings/amount-settings.entity";

@Injectable()
export class TimeEntryResultCalculator {
  constructor(
    protected readonly resultFactorySrv: TimeEntryResultFactory,
    protected readonly durationSettingsSrv: DurationSettingsDataSource,
    protected readonly durationStrategySelector: DurationStrategySelectorService,
    protected readonly amountSettingsSrv: AmountSettingsDataSource) {}

  protected async getDurationService(userId: string): Promise<TimeEntryDurationService> {
    const durationSettings = await this.durationSettingsSrv.getDurationSettings(userId);
    return this.durationStrategySelector.getStrategy(durationSettings.strategy);
  }

  protected async getAmountService(amountSettings: AmountSettings, durationSrv: TimeEntryDurationService, item: TimeEntry): Promise<TimeEntryAmountService> {
    
    const duration = durationSrv.getMinutes(item.start, item.end);
    if (duration < amountSettings.minDuration) {
      return new FixedAmountService(0);
    }
    return new FixedAmountService(amountSettings.hourlyRate);
  }

  async calcResult(userId: string, items: TimeEntry[]): Promise<TimeEntryResultDTO[]>;
  async calcResult(userId: string, item: TimeEntry): Promise<TimeEntryResultDTO>;
  async calcResult(userId: string, arg: TimeEntry | TimeEntry[]) {
    const isArray = Array.isArray(arg);
    const items = isArray ? arg : [arg];

    const durationSrv = await this.getDurationService(userId);
    const amountSettings = await this.amountSettingsSrv.getAmountSettings(userId);

    const results: TimeEntryResultDTO[] = [];
    for (const item of items) {
      const amountSrv = await this.getAmountService(amountSettings, durationSrv, item);
      const resultFactory = this.resultFactorySrv.getFactory(durationSrv, amountSrv);
      results.push(resultFactory(item));
    }
    return isArray ? results : results[0];
  }
}

E aggiungiamo il test:

import { Types } from "mongoose";
import { AmountSettingsDataSource } from "../amount-settings/amount-settings.ds";
import { AmountSettingsStatiDataSource } from "../amount-settings/amount-settings.ds.static";
import { DurationSettingsDataSource } from "../duration-settings/duration-settings.ds";
import { DurationSettingsStaticDataSource } from "../duration-settings/duration-settings.ds.static.service";
import { DurationStrategySelectorService } from "../duration/duration-strategy-selector.service";
import { ExactTimeEntryDurationService } from "../duration/exact-duration.service";
import { TimeEntryResultCalculator } from "./result-calculator.service";
import { TimeEntryResultFactory } from "./time-entry.result.factory";

describe('TimeEntryResultCalculator', () => {
  const resultFactorySrv: TimeEntryResultFactory = new TimeEntryResultFactory();
  let durationSettingsSrv: DurationSettingsDataSource;
  let durationStrategySelector: DurationStrategySelectorService;
  let amountSettingsSrv: AmountSettingsDataSource;
  let resultCalculator: TimeEntryResultCalculator;

  beforeEach(() => {
    durationSettingsSrv = new DurationSettingsStaticDataSource('exact');
    durationStrategySelector = new DurationStrategySelectorService();
    durationStrategySelector.addStrategy('exact', new ExactTimeEntryDurationService());
    amountSettingsSrv = new AmountSettingsStatiDataSource(60);
    resultCalculator = new TimeEntryResultCalculator(resultFactorySrv, durationSettingsSrv, durationStrategySelector, amountSettingsSrv);
  })

  it('should return a record with amount 60', async () => {
    const record = {
      id: new Types.ObjectId().toString(),
      description: 'Test1',
      start: new Date('2024-01-10T10:00:00.000Z'),
      end: new Date('2024-01-10T11:00:00.000Z'),
      billable: true
    }
    const result = await resultCalculator.calcResult('test', record);
    expect(result.amount).toBe(60);
  })

  it('should return a record with amount 30', async () => {
    const record = {
      id: new Types.ObjectId().toString(),
      description: 'Test1',
      start: new Date('2024-01-10T10:00:00.000Z'),
      end: new Date('2024-01-10T10:30:00.000Z'),
      billable: true
    }
    const result = await resultCalculator.calcResult('test', record);
    expect(result.amount).toBe(30);
  })

  it('should return a record with amount 0', async () => {
    const record = {
      id: new Types.ObjectId().toString(),
      description: 'Test1',
      start: new Date('2024-01-10T10:00:00.000Z'),
      end: new Date('2024-01-10T10:00:00.000Z'),
      billable: true
    }
    const result = await resultCalculator.calcResult('test', record);
    expect(result.amount).toBe(0);
  })

  it('should consider min billable', async () => {
    const record = {
      id: new Types.ObjectId().toString(),
      description: 'Test1',
      start: new Date('2024-01-10T10:00:00.000Z'),
      end: new Date('2024-01-10T10:05:00.000Z'),
      billable: true
    }
    const result = await resultCalculator.calcResult('test', record);
    expect(result.amount).toBe(0);
  })

  it('should handle arrays', async () => {
    const records = [
      {
        id: new Types.ObjectId().toString(),
        description: 'Test1',
        start: new Date('2024-01-10T10:00:00.000Z'),
        end: new Date('2024-01-10T11:00:00.000Z'),
        billable: true
      },
      {
        id: new Types.ObjectId().toString(),
        description: 'Test1',
        start: new Date('2024-01-10T10:00:00.000Z'),
        end: new Date('2024-01-10T10:05:00.000Z'),
        billable: true
      },
      {
        id: new Types.ObjectId().toString(),
        description: 'Test1',
        start: new Date('2024-01-10T10:00:00.000Z'),
        end: new Date('2024-01-10T11:00:00.000Z'),
        billable: false
      }
    ];
    const result = await resultCalculator.calcResult('test', records);
    expect(result[0].amount).toBe(60);
    expect(result[1].amount).toBe(0);
    expect(result[2].amount).toBe(0);
  })
})

Il controller diventa:
time-entry.controller.ts

  @Get()
  async list(): Promise<TimeEntryResultDTO[]> {
    const list = await this.dataSource.list();

    return this.resultCalculator.calcResult(FAKE_USERID, list);
  }

e i suoi test:

import { Test, TestingModule } from '@nestjs/testing';
import { TimeEntryController } from './time-entry.controller';
import { TimeEntryDataSource } from './datasource/datasource.service';
import { TimeEntryMockDataSource } from './mock/time-entry.ds.mock.service';
import { Types } from 'mongoose';
import { TimeEntry } from './entities/time-entry.schema';
import { FixedAmountService } from './amount/fixed-amount.service';
import { TimeEntryAmountService } from './amount/amount.service';
import { TimeEntryResultFactory } from './entities/time-entry.result.factory';
import { DurationSettingsDataSource } from './duration-settings/duration-settings.ds';
import { DurationStrategySelectorService } from './duration/duration-strategy-selector.service';
import { ExactTimeEntryDurationService } from './duration/exact-duration.service';
import { DurationSettingsStaticDataSource, STATIC_DURATION_STRATEGY } from './duration-settings/duration-settings.ds.static.service';
import { TimeEntryResultCalculator } from './entities/result-calculator.service';

describe('TimeEntryController', () => {
  let controller: TimeEntryController;
  let dataSource: TimeEntryMockDataSource;
  let spyResult: jest.Mock;
  

  beforeEach(async () => {
    dataSource = new TimeEntryMockDataSource();
    spyResult = jest.fn((_: string, arg: TimeEntry | TimeEntry[]) => {
      return Array.isArray(arg) ? arg.map(() => ({})) : {};
    });
    const app: TestingModule = await Test.createTestingModule({
      controllers: [TimeEntryController],
      providers: [{
        provide: TimeEntryDataSource,
        useValue: dataSource
      },
      {provide: TimeEntryAmountService, useClass: FixedAmountService},
      {provide: TimeEntryResultCalculator, useValue: {calcResult: spyResult} }
    ],
    }).compile();

    controller = app.get<TimeEntryController>(TimeEntryController);
  });

  describe('result calculator', () => {
    const records: TimeEntry[] = [
      {
        id: new Types.ObjectId().toString(),
        description: 'Test1',
        start: new Date(),
        end: new Date(),
        billable: true
      },
      {
        id: new Types.ObjectId().toString(),
        description: 'Test2',
        start: new Date(),
        end: new Date(),
        billable: true
      }
    ];
    beforeEach(() => {
      dataSource.setRecords(records);
    })

    it('LIST: should call the settings provider', async () => {
      await controller.list();
      expect(spyResult).toHaveBeenCalledWith('test', records);
    })
    it('DETAIL: should call the settings provider', async () => {
      await controller.detail(records[0].id.toString());
      expect(spyResult).toHaveBeenCalledWith('test', records[0]);
    })
    it('CREATE: should calculate result', async () => {
      const record = {
        description: 'Test1',
        start: new Date('2024-01-10T10:00:00.000Z'),
        end: new Date('2024-01-10T11:00:00.000Z'),
        billable: true
      }
      await controller.create(record);
      expect(spyResult).toHaveBeenCalled();
    })
  })

  describe('list',  () => {
    it('should return a list of elements"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId().toString(),
          description: 'Test1',
          start: new Date(),
          end: new Date(),
          billable: true
        },
        {
          id: new Types.ObjectId().toString(),
          description: 'Test2',
          start: new Date(),
          end: new Date(),
          billable: true
        }
      ];
      dataSource.setRecords(records);
      return controller.list().then(result => {
        expect(result.length).toBe(records.length);
      })
    });
  });

  describe('detail', () => {
    it('should return a single record"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId().toString(),
          description: 'Test1',
          start: new Date(),
          end: new Date(),
          billable: true
        },
        {
          id: new Types.ObjectId().toString(),
          description: 'Test2',
          start: new Date(),
          end: new Date(),
          billable: true
        }
      ];
      dataSource.setRecords(records);
      return controller.detail(records[1].id.toString()).then(result => {
        expect(result).toStrictEqual({});
      })
    });

    it('should throw an exception if not found"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId().toString(),
          description: 'Test1',
          start: new Date('2024-01-10T10:00:00.000Z'),
          end: new Date('2024-01-10T11:00:00.000Z'),
          billable: false
        }
      ];
      dataSource.setRecords(records);
      return expect(controller.detail('test')).rejects.toThrow('Not found');
    });

  });

  describe('create', () => {
    it('should add a new record', async () => {
      const record = {
        description: 'Test1',
        start: new Date('2024-01-10T10:00:00.000Z'),
        end: new Date('2024-01-10T11:00:00.000Z'),
        billable: true
      }
      return controller.create(record).then(result =>{
        expect(result).toStrictEqual({});
      })
    });
  })
});

Possiamo vedere che il console.log adesso viene chiamato solo una volta.

Il problema è che arrivano nuove specifiche per il nostro programma.
Si decide di introdurre una gerarchia di impostazioni:

class TimeEntry {
	...
	settings: {
		amount: TimeEntryAmountSettings
	};
	project: Project;
	user: User;
	company: Company
}

class Project {
	name: string;
	settings: {
		amount: ProjectAmountSettings,
		duration: ProjectDurationSettings
	};

	company: Company;
}

class User {
	name: string;
	...
	settings: {
		amount: UserAmountSettings
	};

	company: CompanDataSourceDataSourcey;
}

class Company {
	name: string;
	settings: {
		amount: AmountSettings,
		duration: DurationSettings
	}
}

interface UserAmountSettings extends Partial<HourlyRateSettings> {
	// ogni utente può avere il suo costo orario
}

interface ProjectAmountSettings extends Partial<MinBillableSettings> {
	// chiave utente per definire le tariffe orarie diverse degli utenti per questo progetto
	userSettings: {userId: string, settings: HourlyRateSettings }[];
	//in più ha le impostazioni per il minimo tempo fatturabile
}

interface TimeEntryAmountSettings extends Partial<HourlyRateSettings> {
	// ogni time entry può avere il suo costo orario
}

interface ProjectDurationSettings extends Partial<DurationSettings> {
	// ogni progetto può avere le sue impostazioni per la durata
}


Con la seguente logica:

  • la company gestisce le impostazioni generali per durata e tariffa base
  • una TimeEntry è associata a un progetto e ad un utente
  • ogni utente ha la sua tariffa oraria generale
  • ogni progetto può sovrascrivere le tariffe per gli utenti e il calcolo del tempo
  • per ogni TimeEntry si può sovrascrivere la tariffa oraria (vale per qualsiasi utente sia associato all'entry)

Quindi per avere le impostazioni di amount vale la gerarchia:
timeEntry > Project > User > Company

E per il tempo:
project > company

Va tenuto presente che ad ogni livello potrebbero essere sovrascritte solo alcune delle impostazioni, in quel caso devono valere quelle più in alto nella gerarchia.

Creiamo prima la varie classi che ci servono per avere gli altri dati:
assieme a questa operazione facciamo anche un lavoro di refactoring, la struttura della nostra app sta diventando più complessa, è utile fermarsi un attimo e suddividere meglio in moduli e cartelle il nostro codice.

Procedura:

  • identificare i moduli che potrebbero essere separati
  • separare il codice tra moduli e api, i moduli sono le componenti più piccole e le api quelle che mettono assieme la logica
  • rendere i moduli configurabili e spostare la configurazione su AppModule (se poi diventa troppa si può anche vedere di fare dei moduli a parte per la configurazione)
  • aggiornare gli import (sare path typescript)
  • datasource e api per user
  • datasource e api per project
  • datasource e api per company
  • mettiamo i riferimenti tra le entità:
    • utente ha una company
    • timentry ha un progetto e un utente

In nest ogni modulo gestisce i suoi provider (a differenza di angular in cui possiamo dichiarare un provider nell'app e viene utilizzato anche dai "sottomoduli"), quindi dobbiamo usare i concetti di Dynamic Modules e di moduli globali per andare a settare i datasource e le altre variabili.
Questo lo otteniamo tramite la funzione forRoot (o le sue alternative).

Suddividendo l'applicazione in moduli è utile limitare le dipendenze tra questi, in particolare andiamo sempre a individuare quei blocchi funzionali che possono vivere anche per conto proprio, che hanno poche dipendenze ed esportano funzionalità specifiche. Quelli sono i nostri blocchi base, in futuro gli altri moduli che faremo (al momento un esempio sono le api) andranno a importare questi "blocchetti" come dipendenze e li useranno per realizzare funzionalità più complesse.

E' utile a questo punto utilizzare anche le path di typescript, che ci permettono di avere degli alias per le nostre cartelle. In questo modo le stringhe degli import sono più compatte e se dobbiamo spostare un modulo basta cambiare la configurazione.

Assieme a questo è utile anche creare dei file index.ts che avranno la funzione di "interfaccia pubblica" del nostro modulo, esportando solo quello che può essere utilizzato dall'esterno. Attenzione che non c'è nessun vincolo in realtà, è solo un approccio mentale, se vedo che per importare una classe mi suggerisce il percorso completo probabilmente sto usando qualcosa che non dovrei.

interface SettingsDs {
	getSettings() {
	}
}

class CompanyDs implements SettingsDs {
	getSetting() {
		return this.findSettigs();
	}
}

class UserDs implements SettingsDs {
	constructor(base: SettingsDs) {
	}
	
	getSetting() {
		const baseSettings = this.base.getSettings();
		const mySettings = this.findSettigs()
		
		return merge(baseSettings, mySettings);
	}
}

class TimeEntryDs implements SettingsDs {
	constructor(base: SettingsDs) {
	}
	
	getSetting() {
		const baseSettings = this.base.getSettings();
		const mySettings = this.findSettigs()
		
		return merge(baseSettings, mySettings);
	}
}

let source = new CompanyDs();
source = new UserDs(source);
source = new TimeEntryDs(source);