diff --git a/ansible/roles/media-licenses/templates/vidis-sync-cronjob-configmap.yml.j2 b/ansible/roles/media-licenses/templates/vidis-sync-cronjob-configmap.yml.j2 index 4d6e7f3f93..a98d839e20 100644 --- a/ansible/roles/media-licenses/templates/vidis-sync-cronjob-configmap.yml.j2 +++ b/ansible/roles/media-licenses/templates/vidis-sync-cronjob-configmap.yml.j2 @@ -9,3 +9,4 @@ data: NODE_OPTIONS: "--max-old-space-size=3072" NEST_LOG_LEVEL: "error" EXIT_ON_ERROR: "true" + VIDIS_API_CLIENT_BASE_URL: "{{ VIDIS_API_CLIENT_BASE_URL }}" diff --git a/apps/server/src/infra/sync/media-licenses/response/index.ts b/apps/server/src/infra/sync/media-licenses/response/index.ts deleted file mode 100644 index 602b83b8ca..0000000000 --- a/apps/server/src/infra/sync/media-licenses/response/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { VidisResponse } from './vidis.response'; -export { VidisItemResponse } from './vidis-item.response'; diff --git a/apps/server/src/infra/sync/media-licenses/response/vidis-item.response.ts b/apps/server/src/infra/sync/media-licenses/response/vidis-item.response.ts deleted file mode 100644 index ae1de5b6a4..0000000000 --- a/apps/server/src/infra/sync/media-licenses/response/vidis-item.response.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { IsArray, IsNumber, IsOptional, IsString } from 'class-validator'; - -export class VidisItemResponse { - @IsString() - @IsOptional() - educationProviderOrganizationName?: string; - - @IsString() - @IsOptional() - offerDescription?: string; - - @IsString() - offerId!: string; - - @IsString() - @IsOptional() - offerLink?: string; - - @IsString() - @IsOptional() - offerLogo?: string; - - @IsString() - @IsOptional() - offerLongTitle?: string; - - @IsString() - @IsOptional() - offerTitle?: string; - - @IsNumber() - @IsOptional() - offerVersion?: number; - - @IsArray() - schoolActivations!: string[]; -} diff --git a/apps/server/src/infra/sync/media-licenses/response/vidis.response.ts b/apps/server/src/infra/sync/media-licenses/response/vidis.response.ts deleted file mode 100644 index 8cce22055a..0000000000 --- a/apps/server/src/infra/sync/media-licenses/response/vidis.response.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Type } from 'class-transformer'; -import { ArrayMinSize, IsArray, ValidateNested } from 'class-validator'; -import { VidisItemResponse } from './vidis-item.response'; - -export class VidisResponse { - @IsArray() - @ArrayMinSize(1) - @ValidateNested({ each: true }) - @Type(() => VidisItemResponse) - items!: VidisItemResponse[]; -} diff --git a/apps/server/src/infra/sync/media-licenses/service/index.ts b/apps/server/src/infra/sync/media-licenses/service/index.ts index 35886ddd2d..8e764b6c28 100644 --- a/apps/server/src/infra/sync/media-licenses/service/index.ts +++ b/apps/server/src/infra/sync/media-licenses/service/index.ts @@ -1 +1,2 @@ export { VidisSyncService } from './vidis-sync.service'; +export { VidisFetchService } from './vidis-fetch.service'; diff --git a/apps/server/src/infra/sync/media-licenses/service/vidis-fetch.service.spec.ts b/apps/server/src/infra/sync/media-licenses/service/vidis-fetch.service.spec.ts new file mode 100644 index 0000000000..7fe799a00f --- /dev/null +++ b/apps/server/src/infra/sync/media-licenses/service/vidis-fetch.service.spec.ts @@ -0,0 +1,222 @@ +import { DefaultEncryptionService, EncryptionService, SymetricKeyEncryptionService } from '@infra/encryption'; +import { IDMBetreiberApiInterface, PageOfferDTO, VidisClientFactory } from '@infra/vidis-client'; +import { MediaSourceDataFormat } from '@modules/media-source'; +import { MediaSourceBasicAuthConfig } from '@modules/media-source/domain'; +import { MediaSourceBasicAuthConfigNotFoundLoggableException } from '@modules/media-source/loggable'; +import { mediaSourceFactory } from '@modules/media-source/testing'; +import { AxiosErrorLoggable } from '@src/core/error/loggable'; +import { axiosErrorFactory, axiosResponseFactory } from '@shared/testing'; +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AxiosResponse } from 'axios'; +import { vidisPageOfferFactory } from '../testing'; +import { VidisFetchService } from './vidis-fetch.service'; + +describe(VidisFetchService.name, () => { + let module: TestingModule; + let service: VidisFetchService; + let vidisClientFactory: DeepMocked; + let encryptionService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + VidisFetchService, + { + provide: VidisClientFactory, + useValue: createMock(), + }, + { + provide: DefaultEncryptionService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(VidisFetchService); + vidisClientFactory = module.get(VidisClientFactory); + encryptionService = module.get(DefaultEncryptionService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getOfferItemsFromVidis', () => { + describe('when the media source has basic auth config', () => { + describe('when vidis returns the offer items successfully', () => { + const setup = () => { + const mediaSource = mediaSourceFactory.withBasicAuthConfig().build(); + + const axiosResponse = axiosResponseFactory.build({ + data: vidisPageOfferFactory.build(), + }) as AxiosResponse; + + const vidisApiClientMock = createMock(); + vidisApiClientMock.getActivatedOffersByRegion.mockResolvedValueOnce(axiosResponse); + vidisClientFactory.createVidisClient.mockReturnValueOnce(vidisApiClientMock); + + const decryptedUsername = 'un-decrypted'; + const decryptedPassword = 'pw-decrypted'; + encryptionService.decrypt.mockReturnValueOnce(decryptedUsername); + encryptionService.decrypt.mockReturnValueOnce(decryptedPassword); + + return { + mediaSource, + vidisOfferItems: axiosResponse.data.items, + decryptedUsername, + decryptedPassword, + vidisApiClientMock, + }; + }; + + it('should return the vidis offer items', async () => { + const { mediaSource, vidisOfferItems } = setup(); + + const result = await service.getOfferItemsFromVidis(mediaSource); + + expect(result).toEqual(vidisOfferItems); + }); + + it('should decrypt the credentials from basic auth config', async () => { + const { mediaSource } = setup(); + + await service.getOfferItemsFromVidis(mediaSource); + + expect(encryptionService.decrypt).toBeCalledTimes(2); + expect(encryptionService.decrypt).toBeCalledWith(mediaSource.basicAuthConfig?.username); + expect(encryptionService.decrypt).toBeCalledWith(mediaSource.basicAuthConfig?.password); + }); + + it('should create a vidis api client', async () => { + const { mediaSource, decryptedUsername, decryptedPassword } = setup(); + + await service.getOfferItemsFromVidis(mediaSource); + + expect(vidisClientFactory.createVidisClient).toBeCalledWith({ + username: decryptedUsername, + password: decryptedPassword, + }); + }); + + it('should call the vidis endpoint for activated offer items', async () => { + const { mediaSource, vidisApiClientMock } = setup(); + + await service.getOfferItemsFromVidis(mediaSource); + + expect(vidisApiClientMock.getActivatedOffersByRegion).toBeCalledWith('test-region'); + }); + }); + + describe('when vidis returns the no offer items', () => { + const setup = () => { + const mediaSource = mediaSourceFactory.withBasicAuthConfig().build(); + + const axiosResponse = axiosResponseFactory.build({ + data: vidisPageOfferFactory.build({ items: undefined }), + }) as AxiosResponse; + + const vidisApiClientMock = createMock(); + vidisApiClientMock.getActivatedOffersByRegion.mockResolvedValueOnce(axiosResponse); + vidisClientFactory.createVidisClient.mockReturnValueOnce(vidisApiClientMock); + + const basicAuth = mediaSource.basicAuthConfig as MediaSourceBasicAuthConfig; + encryptionService.decrypt.mockReturnValueOnce(basicAuth.username); + encryptionService.decrypt.mockReturnValueOnce(basicAuth.password); + + return { + mediaSource, + }; + }; + + it('should return an empty array', async () => { + const { mediaSource } = setup(); + + const result = await service.getOfferItemsFromVidis(mediaSource); + + expect(result.length).toEqual(0); + }); + }); + + describe('when an axios error is thrown', () => { + const setup = () => { + const mediaSource = mediaSourceFactory.withBasicAuthConfig().build(); + + const axiosError = axiosErrorFactory.build(); + + const vidisApiClientMock = createMock(); + vidisApiClientMock.getActivatedOffersByRegion.mockRejectedValueOnce(axiosError); + vidisClientFactory.createVidisClient.mockReturnValueOnce(vidisApiClientMock); + + const basicAuth = mediaSource.basicAuthConfig as MediaSourceBasicAuthConfig; + encryptionService.decrypt.mockReturnValueOnce(basicAuth.username); + encryptionService.decrypt.mockReturnValueOnce(basicAuth.password); + + return { + mediaSource, + axiosError, + }; + }; + + it('should throw a AxiosErrorLoggable', async () => { + const { mediaSource, axiosError } = setup(); + + const promise = service.getOfferItemsFromVidis(mediaSource); + + await expect(promise).rejects.toThrow(new AxiosErrorLoggable(axiosError, 'VIDIS_GET_OFFER_ITEMS_FAILED')); + }); + }); + + describe('when an unknown error is thrown', () => { + const setup = () => { + const mediaSource = mediaSourceFactory.withBasicAuthConfig().build(); + + const unknownError = new Error(); + + const vidisApiClientMock = createMock(); + vidisApiClientMock.getActivatedOffersByRegion.mockRejectedValueOnce(unknownError); + vidisClientFactory.createVidisClient.mockReturnValueOnce(vidisApiClientMock); + + const basicAuth = mediaSource.basicAuthConfig as MediaSourceBasicAuthConfig; + encryptionService.decrypt.mockReturnValueOnce(basicAuth.username); + encryptionService.decrypt.mockReturnValueOnce(basicAuth.password); + + return { + mediaSource, + unknownError, + }; + }; + + it('should throw the unknown error', async () => { + const { mediaSource, unknownError } = setup(); + + const promise = service.getOfferItemsFromVidis(mediaSource); + + await expect(promise).rejects.toThrow(unknownError); + }); + }); + }); + + describe('when the media source has no basic auth config ', () => { + const setup = () => { + const mediaSource = mediaSourceFactory.build({ basicAuthConfig: undefined }); + + return { mediaSource }; + }; + + it('should throw an MediaSourceBasicAuthConfigNotFoundLoggableException', async () => { + const { mediaSource } = setup(); + + const promise = service.getOfferItemsFromVidis(mediaSource); + + await expect(promise).rejects.toThrow( + new MediaSourceBasicAuthConfigNotFoundLoggableException(mediaSource.id, MediaSourceDataFormat.VIDIS) + ); + }); + }); + }); +}); diff --git a/apps/server/src/infra/sync/media-licenses/service/vidis-fetch.service.ts b/apps/server/src/infra/sync/media-licenses/service/vidis-fetch.service.ts new file mode 100644 index 0000000000..0b59c86e96 --- /dev/null +++ b/apps/server/src/infra/sync/media-licenses/service/vidis-fetch.service.ts @@ -0,0 +1,49 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DefaultEncryptionService, EncryptionService } from '@infra/encryption'; +import { VidisClientFactory, IDMBetreiberApiInterface, PageOfferDTO, OfferDTO } from '@infra/vidis-client'; +import { MediaSource, MediaSourceDataFormat } from '@modules/media-source'; +import { MediaSourceBasicAuthConfigNotFoundLoggableException } from '@modules/media-source/loggable'; +import { AxiosResponse, isAxiosError } from 'axios'; +import { AxiosErrorLoggable } from '@src/core/error/loggable'; + +@Injectable() +export class VidisFetchService { + constructor( + private readonly vidisClientFactory: VidisClientFactory, + @Inject(DefaultEncryptionService) private readonly encryptionService: EncryptionService + ) {} + + public async getOfferItemsFromVidis(mediaSource: MediaSource): Promise { + if (!mediaSource.basicAuthConfig) { + throw new MediaSourceBasicAuthConfigNotFoundLoggableException(mediaSource.id, MediaSourceDataFormat.VIDIS); + } + + const vidisClient: IDMBetreiberApiInterface = this.vidisClientFactory.createVidisClient(); + + // TODO: env var + const region = 'test-region'; + const decryptedUsername = this.encryptionService.decrypt(mediaSource.basicAuthConfig.username); + const decryptedPassword = this.encryptionService.decrypt(mediaSource.basicAuthConfig.password); + const basicAuthEncoded = btoa(`${decryptedUsername}:${decryptedPassword}`); + + try { + const axiosResponse: AxiosResponse = await vidisClient.getActivatedOffersByRegion( + region, + undefined, + undefined, + { + headers: { Authorization: `Basic ${basicAuthEncoded}` }, + } + ); + const offerItems: OfferDTO[] = axiosResponse.data.items ?? []; + + return offerItems; + } catch (error: unknown) { + if (isAxiosError(error)) { + throw new AxiosErrorLoggable(error, 'VIDIS_GET_OFFER_ITEMS_FAILED'); + } else { + throw error; + } + } + } +} diff --git a/apps/server/src/infra/sync/media-licenses/service/vidis-sync-service.response.spec.ts b/apps/server/src/infra/sync/media-licenses/service/vidis-sync-service.response.spec.ts deleted file mode 100644 index b215e1153f..0000000000 --- a/apps/server/src/infra/sync/media-licenses/service/vidis-sync-service.response.spec.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { HttpModule } from '@nestjs/axios'; -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { Logger } from '@src/core/logger'; -import { DefaultEncryptionService, EncryptionService, SymetricKeyEncryptionService } from '@infra/encryption'; -import { mediaSourceFactory } from '@modules/media-source/testing'; -import { MediaSourceDataFormat, MediaSourceService } from '@modules/media-source'; -import { MediaSchoolLicenseService } from '@modules/school-license'; -import { SchoolService } from '@modules/school'; -import MockAdapter from 'axios-mock-adapter'; -import axios from 'axios'; -import { vidisResponseFactory } from '../testing'; -import { VidisSyncService } from './vidis-sync.service'; - -describe(`${VidisSyncService.name} Integration`, () => { - let module: TestingModule; - let axiosMock: MockAdapter; - let service: VidisSyncService; - let encryptionService: DeepMocked; - - beforeAll(async () => { - module = await Test.createTestingModule({ - imports: [HttpModule], - providers: [ - VidisSyncService, - { provide: MediaSchoolLicenseService, useValue: createMock() }, - { provide: SchoolService, useValue: createMock() }, - { provide: MediaSourceService, useValue: createMock() }, - { provide: Logger, useValue: createMock() }, - { provide: DefaultEncryptionService, useValue: createMock() }, - ], - }).compile(); - - service = module.get(VidisSyncService); - encryptionService = module.get(DefaultEncryptionService); - axiosMock = new MockAdapter(axios); - }); - - afterAll(async () => { - await module.close(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('getSchoolActivationsFromVidis', () => { - describe('when the vidis media source is passed', () => { - const setup = () => { - const mediaSource = mediaSourceFactory.withBasicAuthConfig().build({ format: MediaSourceDataFormat.VIDIS }); - const vidisResponse = vidisResponseFactory.build(); - - encryptionService.decrypt.mockReturnValueOnce('username'); - encryptionService.decrypt.mockReturnValueOnce('password'); - - axiosMock.onGet().replyOnce(HttpStatus.OK, vidisResponse); - - return { mediaSource, vidisResponse }; - }; - - it('should return the school activation items from vidis', async () => { - const { mediaSource, vidisResponse } = setup(); - - const items = await service.getSchoolActivationsFromVidis(mediaSource); - - expect(items).toEqual(vidisResponse.items); - }); - }); - }); -}); diff --git a/apps/server/src/infra/sync/media-licenses/service/vidis-sync.service.spec.ts b/apps/server/src/infra/sync/media-licenses/service/vidis-sync.service.spec.ts index ce41a581f8..ebd47e3529 100644 --- a/apps/server/src/infra/sync/media-licenses/service/vidis-sync.service.spec.ts +++ b/apps/server/src/infra/sync/media-licenses/service/vidis-sync.service.spec.ts @@ -1,51 +1,28 @@ -import { MediaSourceService, MediaSourceDataFormat } from '@modules/media-source'; -import { MediaSourceBasicAuthConfig } from '@modules/media-source/domain'; -import { - MediaSourceBasicAuthConfigNotFoundLoggableException, - MediaSourceForSyncNotFoundLoggableException, -} from '@modules/media-source/loggable'; +import { SchoolForSchoolMediaLicenseSyncNotFoundLoggable } from '@infra/sync/media-licenses/loggable'; +import { OfferDTO } from '@infra/vidis-client'; import { mediaSourceFactory } from '@modules/media-source/testing'; import { MediaSchoolLicenseService } from '@modules/school-license/service/media-school-license.service'; import { MediaSchoolLicense, SchoolLicenseType } from '@modules/school-license'; import { mediaSchoolLicenseFactory } from '@modules/school-license/testing'; -import { SchoolForSchoolMediaLicenseSyncNotFoundLoggable } from '@infra/sync/media-licenses/loggable'; import { School, SchoolService } from '@modules/school'; import { schoolFactory } from '@modules/school/testing'; -import { axiosErrorFactory, axiosResponseFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; -import { AxiosErrorLoggable } from '@src/core/error/loggable'; -import { DefaultEncryptionService, EncryptionService, SymetricKeyEncryptionService } from '@infra/encryption'; import { Test, TestingModule } from '@nestjs/testing'; -import { HttpService } from '@nestjs/axios'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { of, throwError } from 'rxjs'; -import { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; -import { VidisItemResponse, VidisResponse } from '../response'; -import { vidisResponseFactory, vidisItemResponseFactory } from '../testing'; +import { vidisOfferItemFactory } from '../testing'; import { VidisSyncService } from './vidis-sync.service'; describe(VidisSyncService.name, () => { let module: TestingModule; - let vidisSyncService: VidisSyncService; - let httpService: DeepMocked; - let mediaSourceService: DeepMocked; + let service: VidisSyncService; let mediaSchoolLicenseService: DeepMocked; let schoolService: DeepMocked; let logger: DeepMocked; - let encryptionService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ providers: [ VidisSyncService, - { - provide: HttpService, - useValue: createMock(), - }, - { - provide: MediaSourceService, - useValue: createMock(), - }, { provide: MediaSchoolLicenseService, useValue: createMock(), @@ -58,20 +35,13 @@ describe(VidisSyncService.name, () => { provide: Logger, useValue: createMock(), }, - { - provide: DefaultEncryptionService, - useValue: createMock(), - }, ], }).compile(); - vidisSyncService = module.get(VidisSyncService); - httpService = module.get(HttpService); - mediaSourceService = module.get(MediaSourceService); + service = module.get(VidisSyncService); mediaSchoolLicenseService = module.get(MediaSchoolLicenseService); schoolService = module.get(SchoolService); logger = module.get(Logger); - encryptionService = module.get(DefaultEncryptionService); }); afterAll(async () => { @@ -82,198 +52,13 @@ describe(VidisSyncService.name, () => { jest.clearAllMocks(); }); - describe('getVidisMediaSource', () => { - describe('when the vidis media source exists', () => { - const setup = () => { - const vidisMediaSource = mediaSourceFactory.build({ format: MediaSourceDataFormat.VIDIS }); - - mediaSourceService.findByFormat.mockResolvedValueOnce(vidisMediaSource); - - return { - vidisMediaSource, - }; - }; - - it('should find the media source by format', async () => { - setup(); - - await vidisSyncService.getVidisMediaSource(); - - expect(mediaSourceService.findByFormat).toBeCalledWith(MediaSourceDataFormat.VIDIS); - }); - - it('should return the vidis media source', async () => { - const { vidisMediaSource } = setup(); - - const result = await vidisSyncService.getVidisMediaSource(); - - expect(result).toEqual(vidisMediaSource); - }); - }); - - describe('when the vidis media source does not exist', () => { - const setup = () => { - mediaSourceService.findByFormat.mockResolvedValueOnce(null); - }; - - it('should throw an MediaSourceForSyncNotFoundLoggableException', async () => { - setup(); - - const promise = vidisSyncService.getVidisMediaSource(); - - await expect(promise).rejects.toThrow( - new MediaSourceForSyncNotFoundLoggableException(MediaSourceDataFormat.VIDIS) - ); - }); - }); - }); - - describe('getSchoolActivationsFromVidis', () => { - describe('when the provided media source has a valid basic auth config', () => { - describe('when vidis successfully returns the school activations', () => { - const setup = () => { - const mediaSource = mediaSourceFactory.withBasicAuthConfig().build(); - const basicAuthConfig = mediaSource.basicAuthConfig as MediaSourceBasicAuthConfig; - - const vidisItemResponse: VidisItemResponse[] = vidisItemResponseFactory.buildList(3); - vidisItemResponse[0].offerTitle = 'Other Title'; - - const vidisResponse: VidisResponse = vidisResponseFactory.build(); - vidisResponse.items = vidisItemResponse; - - const axiosResponse: AxiosResponse = axiosResponseFactory.build({ - data: vidisResponse, - }); - - httpService.get.mockReturnValueOnce(of(axiosResponse)); - encryptionService.decrypt.mockReturnValueOnce(basicAuthConfig.username); - encryptionService.decrypt.mockReturnValueOnce(basicAuthConfig.password); - - return { - mediaSource, - axiosResponse, - }; - }; - - it('should return school activation items from vidis', async () => { - const { mediaSource, axiosResponse } = setup(); - - const result = await vidisSyncService.getSchoolActivationsFromVidis(mediaSource); - - expect(result).toEqual(axiosResponse.data.items); - }); - - it('should decrypt the credentials from the basic auth config', async () => { - const { mediaSource } = setup(); - - await vidisSyncService.getSchoolActivationsFromVidis(mediaSource); - - const basicAuthConfig = mediaSource.basicAuthConfig as MediaSourceBasicAuthConfig; - - expect(encryptionService.decrypt).toBeCalledWith(basicAuthConfig.username); - expect(encryptionService.decrypt).toBeCalledWith(basicAuthConfig.password); - }); - - it('should call the vidis endpoint for school activations with encoded credentials', async () => { - const { mediaSource } = setup(); - - await vidisSyncService.getSchoolActivationsFromVidis(mediaSource); - - const basicAuthConfig = mediaSource.basicAuthConfig as MediaSourceBasicAuthConfig; - const endpoint: string = new URL(basicAuthConfig.authEndpoint).toString(); - const unencodedCredentials = `${basicAuthConfig.username}:${basicAuthConfig.password}`; - const axiosConfig: AxiosRequestConfig = { - headers: { - Authorization: expect.not.stringMatching(`Basic ${unencodedCredentials}`) as string, - 'Content-Type': 'application/json', - }, - }; - - expect(httpService.get).toBeCalledWith(endpoint, axiosConfig); - }); - }); - - describe('when there is an axios error when fetching the school activations', () => { - const setup = () => { - const mediaSource = mediaSourceFactory.withBasicAuthConfig().build(); - const basicAuthConfig = mediaSource.basicAuthConfig as MediaSourceBasicAuthConfig; - const axiosError: AxiosError = axiosErrorFactory.build(); - - httpService.get.mockReturnValueOnce(throwError(() => axiosError)); - encryptionService.decrypt.mockReturnValueOnce(basicAuthConfig.username); - encryptionService.decrypt.mockReturnValueOnce(basicAuthConfig.password); - - return { - mediaSource, - axiosError, - }; - }; - - it('should throw an AxiosErrorLoggable', async () => { - const { mediaSource, axiosError } = setup(); - - const promise = vidisSyncService.getSchoolActivationsFromVidis(mediaSource); - - await expect(promise).rejects.toThrow( - new AxiosErrorLoggable(axiosError, 'VIDIS_GET_SCHOOL_ACTIVATIONS_FAILED') - ); - }); - }); - - describe('when there is an unknown error when fetching the school activations', () => { - const setup = () => { - const mediaSource = mediaSourceFactory.withBasicAuthConfig().build(); - const basicAuthConfig = mediaSource.basicAuthConfig as MediaSourceBasicAuthConfig; - const unknownError = new Error(); - - httpService.get.mockReturnValueOnce(throwError(() => unknownError)); - encryptionService.decrypt.mockReturnValueOnce(basicAuthConfig.username); - encryptionService.decrypt.mockReturnValueOnce(basicAuthConfig.password); - - return { - mediaSource, - unknownError, - }; - }; - - it('should throw the unknown error', async () => { - const { mediaSource, unknownError } = setup(); - - const promise = vidisSyncService.getSchoolActivationsFromVidis(mediaSource); - - await expect(promise).rejects.toThrow(unknownError); - }); - }); - }); - - describe('when the provided media source has no basic auth config', () => { - const setup = () => { - const mediaSource = mediaSourceFactory.build({ basicAuthConfig: undefined }); - - return { - mediaSource, - }; - }; - - it('should throw an MediaSourceBasicAuthConfigNotFoundLoggableException', async () => { - const { mediaSource } = setup(); - - const promise = vidisSyncService.getSchoolActivationsFromVidis(mediaSource); - - await expect(promise).rejects.toThrow( - new MediaSourceBasicAuthConfigNotFoundLoggableException(mediaSource.id, MediaSourceDataFormat.VIDIS) - ); - }); - }); - }); - describe('syncMediaSchoolLicenses', () => { describe('when the vidis media source and school activation items are given', () => { describe('when the school activations provided does not exist in SVS', () => { const setup = () => { const mediaSource = mediaSourceFactory.build(); const officialSchoolNumbers = ['00100', '00200']; - const items = vidisItemResponseFactory.buildList(2, { schoolActivations: officialSchoolNumbers }); + const items: OfferDTO[] = vidisOfferItemFactory.buildList(2, { schoolActivations: officialSchoolNumbers }); const schools: School[] = officialSchoolNumbers.map((officialSchoolNumber: string) => schoolFactory.build({ officialSchoolNumber }) @@ -303,7 +88,7 @@ describe(VidisSyncService.name, () => { it('should save the school activations as new school media licenses', async () => { const { mediaSource, items, schoolIdMatch, expectedSavedLicenseCount } = setup(); - await vidisSyncService.syncMediaSchoolLicenses(mediaSource, items); + await service.syncMediaSchoolLicenses(mediaSource, items); expect(mediaSchoolLicenseService.saveMediaSchoolLicense).toHaveBeenCalledTimes(expectedSavedLicenseCount); expect(mediaSchoolLicenseService.saveMediaSchoolLicense).toHaveBeenCalledWith( @@ -311,7 +96,7 @@ describe(VidisSyncService.name, () => { id: expect.any(String), type: SchoolLicenseType.MEDIA_LICENSE, schoolId: expect.stringMatching(schoolIdMatch) as string, - mediumId: items[0].offerId, + mediumId: `${items[0].offerId as number}`, mediaSource, } as MediaSchoolLicense) ); @@ -322,12 +107,13 @@ describe(VidisSyncService.name, () => { const setup = () => { const mediaSource = mediaSourceFactory.build(); const officialSchoolNumbers = ['00100']; - const items = vidisItemResponseFactory.buildList(2, { schoolActivations: officialSchoolNumbers }); + const items: OfferDTO[] = vidisOfferItemFactory.buildList(2, { schoolActivations: officialSchoolNumbers }); const school = schoolFactory.build({ officialSchoolNumber: officialSchoolNumbers[0] }); + const mediumId = `${items[0].offerId as number}`; const existingMediaSchoolLicense = mediaSchoolLicenseFactory.build({ schoolId: school.id, - mediumId: items[0].offerId, + mediumId, mediaSource, }); @@ -346,7 +132,7 @@ describe(VidisSyncService.name, () => { it('it should not save the existing school activations as new school media licenses', async () => { const { mediaSource, items } = setup(); - await vidisSyncService.syncMediaSchoolLicenses(mediaSource, items); + await service.syncMediaSchoolLicenses(mediaSource, items); expect(mediaSchoolLicenseService.saveMediaSchoolLicense).not.toHaveBeenCalled(); }); @@ -356,12 +142,13 @@ describe(VidisSyncService.name, () => { describe('when the school has an official school number', () => { const setup = () => { const mediaSource = mediaSourceFactory.build(); - const items = vidisItemResponseFactory.buildList(2, { schoolActivations: [] }); + const items: OfferDTO[] = vidisOfferItemFactory.buildList(2, { schoolActivations: [] }); const school = schoolFactory.build({ officialSchoolNumber: '00100' }); + const mediumId = `${items[0].offerId as number}`; const existingMediaSchoolLicense = mediaSchoolLicenseFactory.build({ schoolId: school.id, - mediumId: items[0].offerId, + mediumId, mediaSource, }); @@ -381,7 +168,7 @@ describe(VidisSyncService.name, () => { it('it should delete the unavailable existing media school licenses', async () => { const { mediaSource, items, existingMediaSchoolLicense } = setup(); - await vidisSyncService.syncMediaSchoolLicenses(mediaSource, items); + await service.syncMediaSchoolLicenses(mediaSource, items); expect(mediaSchoolLicenseService.deleteSchoolLicense).toHaveBeenCalledWith(existingMediaSchoolLicense); }); @@ -390,12 +177,13 @@ describe(VidisSyncService.name, () => { describe('when the school does not have an official school number', () => { const setup = () => { const mediaSource = mediaSourceFactory.build(); - const items = vidisItemResponseFactory.buildList(2, { schoolActivations: [] }); + const items: OfferDTO[] = vidisOfferItemFactory.buildList(2, { schoolActivations: [] }); const school = schoolFactory.build({ officialSchoolNumber: undefined }); + const mediumId = `${items[0].offerId as number}`; const existingMediaSchoolLicense = mediaSchoolLicenseFactory.build({ schoolId: school.id, - mediumId: items[0].offerId, + mediumId, mediaSource, }); @@ -414,7 +202,7 @@ describe(VidisSyncService.name, () => { it('it should not delete the existing media school licenses', async () => { const { mediaSource, items } = setup(); - await vidisSyncService.syncMediaSchoolLicenses(mediaSource, items); + await service.syncMediaSchoolLicenses(mediaSource, items); expect(mediaSchoolLicenseService.deleteSchoolLicense).not.toHaveBeenCalled(); }); @@ -425,7 +213,7 @@ describe(VidisSyncService.name, () => { const setup = () => { const mediaSource = mediaSourceFactory.build(); const missingSchoolNumbers = ['00100', '00200']; - const items = vidisItemResponseFactory.buildList(1, { schoolActivations: missingSchoolNumbers }); + const items: OfferDTO[] = vidisOfferItemFactory.buildList(1, { schoolActivations: missingSchoolNumbers }); mediaSchoolLicenseService.findMediaSchoolLicensesByMediumId.mockResolvedValueOnce([]); schoolService.getSchoolByOfficialSchoolNumber.mockResolvedValue(null); @@ -440,7 +228,7 @@ describe(VidisSyncService.name, () => { it('should log a SchoolForSchoolMediaLicenseSyncNotFoundLoggable', async () => { const { mediaSource, items, missingSchoolNumbers } = setup(); - await vidisSyncService.syncMediaSchoolLicenses(mediaSource, items); + await service.syncMediaSchoolLicenses(mediaSource, items); missingSchoolNumbers.forEach((schoolNumber: string) => { expect(logger.info).toHaveBeenCalledWith(new SchoolForSchoolMediaLicenseSyncNotFoundLoggable(schoolNumber)); @@ -452,7 +240,7 @@ describe(VidisSyncService.name, () => { const setup = () => { const mediaSource = mediaSourceFactory.build(); const schoolActivations = ['DE-NI-00100', 'DE-NI-00200', 'DE-NI-00300']; - const items = vidisItemResponseFactory.buildList(1, { schoolActivations }); + const items: OfferDTO[] = vidisOfferItemFactory.buildList(1, { schoolActivations }); const expectedSchoolNumbers = ['00100', '00200', '00300']; const schools: School[] = expectedSchoolNumbers.map((officialSchoolNumber: string) => @@ -475,7 +263,7 @@ describe(VidisSyncService.name, () => { it('should get the correct official school number from the school activations', async () => { const { mediaSource, items, expectedSchoolNumbers } = setup(); - await vidisSyncService.syncMediaSchoolLicenses(mediaSource, items); + await service.syncMediaSchoolLicenses(mediaSource, items); expectedSchoolNumbers.forEach((schoolNumber: string) => { expect(schoolService.getSchoolByOfficialSchoolNumber).toBeCalledWith(schoolNumber); @@ -485,7 +273,7 @@ describe(VidisSyncService.name, () => { it('should not throw any error', async () => { const { mediaSource, items } = setup(); - const promise = vidisSyncService.syncMediaSchoolLicenses(mediaSource, items); + const promise = service.syncMediaSchoolLicenses(mediaSource, items); await expect(promise).resolves.not.toThrow(); }); @@ -495,7 +283,7 @@ describe(VidisSyncService.name, () => { const setup = () => { const mediaSource = mediaSourceFactory.build(); const schoolActivations = ['00100', '00200', '00300']; - const items = vidisItemResponseFactory.buildList(1, { schoolActivations }); + const items: OfferDTO[] = vidisOfferItemFactory.buildList(1, { schoolActivations }); const expectedSchoolNumbers = ['00100', '00200', '00300']; const schools: School[] = expectedSchoolNumbers.map((officialSchoolNumber: string) => @@ -518,7 +306,7 @@ describe(VidisSyncService.name, () => { it('should get the correct official school number from the school activations', async () => { const { mediaSource, items, expectedSchoolNumbers } = setup(); - await vidisSyncService.syncMediaSchoolLicenses(mediaSource, items); + await service.syncMediaSchoolLicenses(mediaSource, items); expectedSchoolNumbers.forEach((schoolNumber: string) => { expect(schoolService.getSchoolByOfficialSchoolNumber).toBeCalledWith(schoolNumber); @@ -528,7 +316,7 @@ describe(VidisSyncService.name, () => { it('should not throw any error', async () => { const { mediaSource, items } = setup(); - const promise = vidisSyncService.syncMediaSchoolLicenses(mediaSource, items); + const promise = service.syncMediaSchoolLicenses(mediaSource, items); await expect(promise).resolves.not.toThrow(); }); diff --git a/apps/server/src/infra/sync/media-licenses/service/vidis-sync.service.ts b/apps/server/src/infra/sync/media-licenses/service/vidis-sync.service.ts index 0bde730700..190cdb81d4 100644 --- a/apps/server/src/infra/sync/media-licenses/service/vidis-sync.service.ts +++ b/apps/server/src/infra/sync/media-licenses/service/vidis-sync.service.ts @@ -1,70 +1,39 @@ -import { MediaSource, MediaSourceDataFormat, MediaSourceService } from '@modules/media-source'; -import { - MediaSourceForSyncNotFoundLoggableException, - MediaSourceBasicAuthConfigNotFoundLoggableException, -} from '@modules/media-source/loggable'; +import { MediaSource } from '@modules/media-source'; import { MediaSchoolLicenseService, MediaSchoolLicense, SchoolLicenseType } from '@modules/school-license'; import { School, SchoolService } from '@modules/school'; -import { DefaultEncryptionService, EncryptionService } from '@infra/encryption'; +import { OfferDTO } from '@infra/vidis-client'; import { Logger } from '@src/core/logger'; -import { AxiosErrorLoggable } from '@src/core/error/loggable'; import { ObjectId } from '@mikro-orm/mongodb'; -import { HttpService } from '@nestjs/axios'; -import { Inject, Injectable } from '@nestjs/common'; -import { AxiosResponse, isAxiosError } from 'axios'; -import { lastValueFrom, Observable } from 'rxjs'; -import { VidisResponse, VidisItemResponse } from '../response'; +import { Injectable } from '@nestjs/common'; import { SchoolForSchoolMediaLicenseSyncNotFoundLoggable } from '../loggable'; @Injectable() export class VidisSyncService { constructor( - private readonly httpService: HttpService, - private readonly mediaSourceService: MediaSourceService, private readonly mediaSchoolLicenseService: MediaSchoolLicenseService, private readonly schoolService: SchoolService, - private readonly logger: Logger, - @Inject(DefaultEncryptionService) private readonly encryptionService: EncryptionService + private readonly logger: Logger ) {} - public async getVidisMediaSource(): Promise { - const mediaSource: MediaSource | null = await this.mediaSourceService.findByFormat(MediaSourceDataFormat.VIDIS); - if (!mediaSource) { - throw new MediaSourceForSyncNotFoundLoggableException(MediaSourceDataFormat.VIDIS); - } - - return mediaSource; - } - - public async getSchoolActivationsFromVidis(mediaSource: MediaSource): Promise { - if (!mediaSource.basicAuthConfig || !mediaSource.basicAuthConfig.authEndpoint) { - throw new MediaSourceBasicAuthConfigNotFoundLoggableException(mediaSource.id, MediaSourceDataFormat.VIDIS); - } - - const vidisResponse: VidisResponse = await this.getRequest( - new URL(`${mediaSource.basicAuthConfig.authEndpoint}`), - this.encryptionService.decrypt(mediaSource.basicAuthConfig.username), - this.encryptionService.decrypt(mediaSource.basicAuthConfig.password) - ); + public async syncMediaSchoolLicenses(mediaSource: MediaSource, vidisOfferItems: OfferDTO[]): Promise { + const syncItemPromises: Promise[] = vidisOfferItems.map(async (item: OfferDTO): Promise => { + if (!item.schoolActivations || !item.offerId) { + return; + } - const { items } = vidisResponse; + const mediumId = `${item.offerId}`; - return items; - } - - public async syncMediaSchoolLicenses(mediaSource: MediaSource, items: VidisItemResponse[]): Promise { - const syncItemPromises: Promise[] = items.map(async (item: VidisItemResponse): Promise => { - const schoolNumbersFromVidis = item.schoolActivations.map((activation) => this.removePrefix(activation)); + const officialSchoolNumbers = item.schoolActivations.map((activation) => this.removePrefix(activation)); let existingLicenses: MediaSchoolLicense[] = - await this.mediaSchoolLicenseService.findMediaSchoolLicensesByMediumId(item.offerId); + await this.mediaSchoolLicenseService.findMediaSchoolLicensesByMediumId(mediumId); if (existingLicenses.length) { - await this.removeNoLongerAvailableLicenses(existingLicenses, schoolNumbersFromVidis); - existingLicenses = await this.mediaSchoolLicenseService.findMediaSchoolLicensesByMediumId(item.offerId); + await this.removeNoLongerAvailableLicenses(existingLicenses, officialSchoolNumbers); + existingLicenses = await this.mediaSchoolLicenseService.findMediaSchoolLicensesByMediumId(mediumId); } - const saveNewLicensesPromises: Promise[] = schoolNumbersFromVidis.map( + const saveNewLicensesPromises: Promise[] = officialSchoolNumbers.map( async (schoolNumber: string): Promise => { const school: School | null = await this.schoolService.getSchoolByOfficialSchoolNumber(schoolNumber); @@ -80,7 +49,7 @@ export class VidisSyncService { type: SchoolLicenseType.MEDIA_LICENSE, schoolId: school.id, mediaSource, - mediumId: item.offerId, + mediumId, }); await this.mediaSchoolLicenseService.saveMediaSchoolLicense(newLicense); } @@ -94,27 +63,6 @@ export class VidisSyncService { await Promise.all(syncItemPromises); } - private async getRequest(url: URL, username: string, password: string): Promise { - const encodedCredentials = btoa(`${username}:${password}`); - const observable: Observable> = this.httpService.get(url.toString(), { - headers: { - Authorization: `Basic ${encodedCredentials}`, - 'Content-Type': 'application/json', - }, - }); - - try { - const responseToken: AxiosResponse = await lastValueFrom(observable); - return responseToken.data; - } catch (error: unknown) { - if (isAxiosError(error)) { - throw new AxiosErrorLoggable(error, 'VIDIS_GET_SCHOOL_ACTIVATIONS_FAILED'); - } else { - throw error; - } - } - } - private removePrefix(input: string): string { return input.replace(/^.*?(\d{5})$/, '$1'); } diff --git a/apps/server/src/infra/sync/media-licenses/strategy/vidis-sync.strategy.spec.ts b/apps/server/src/infra/sync/media-licenses/strategy/vidis-sync.strategy.spec.ts index 0ca9dd4e95..e8c1206005 100644 --- a/apps/server/src/infra/sync/media-licenses/strategy/vidis-sync.strategy.spec.ts +++ b/apps/server/src/infra/sync/media-licenses/strategy/vidis-sync.strategy.spec.ts @@ -1,15 +1,19 @@ +import { MediaSourceDataFormat, MediaSourceService } from '@modules/media-source'; +import { MediaSourceForSyncNotFoundLoggableException } from '@modules/media-source/loggable'; import { mediaSourceFactory } from '@modules/media-source/testing'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { SyncStrategyTarget } from '../../sync-strategy.types'; -import { VidisSyncService } from '../service'; -import { vidisItemResponseFactory } from '../testing'; +import { VidisFetchService, VidisSyncService } from '../service'; +import { vidisOfferItemFactory } from '../testing'; import { VidisSyncStrategy } from './vidis-sync.strategy'; describe(VidisSyncService.name, () => { let module: TestingModule; - let vidisSyncStrategy: VidisSyncStrategy; + let strategy: VidisSyncStrategy; let vidisSyncService: DeepMocked; + let vidisFetchService: DeepMocked; + let mediaSourceService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -19,11 +23,21 @@ describe(VidisSyncService.name, () => { provide: VidisSyncService, useValue: createMock(), }, + { + provide: VidisFetchService, + useValue: createMock(), + }, + { + provide: MediaSourceService, + useValue: createMock(), + }, ], }).compile(); - vidisSyncStrategy = module.get(VidisSyncStrategy); + strategy = module.get(VidisSyncStrategy); vidisSyncService = module.get(VidisSyncService); + vidisFetchService = module.get(VidisFetchService); + mediaSourceService = module.get(MediaSourceService); }); afterAll(async () => { @@ -37,7 +51,7 @@ describe(VidisSyncService.name, () => { describe('getType', () => { describe('when getType is called', () => { it('should return vidis sync strategy target', () => { - const result = vidisSyncStrategy.getType(); + const result = strategy.getType(); expect(result).toEqual(SyncStrategyTarget.VIDIS); }); @@ -45,28 +59,57 @@ describe(VidisSyncService.name, () => { }); describe('sync', () => { - describe('when sync is called', () => { + describe('when the vidis media source is found', () => { const setup = () => { - const mediaSource = mediaSourceFactory.build(); - const vidisItemResponses = vidisItemResponseFactory.buildList(3); + const format = MediaSourceDataFormat.VIDIS; + const mediaSource = mediaSourceFactory.withBasicAuthConfig().build({ format }); + const vidisOfferItems = vidisOfferItemFactory.buildList(3); + + mediaSourceService.findByFormat.mockResolvedValueOnce(mediaSource); - vidisSyncService.getVidisMediaSource.mockResolvedValueOnce(mediaSource); - vidisSyncService.getSchoolActivationsFromVidis.mockResolvedValueOnce(vidisItemResponses); + vidisFetchService.getOfferItemsFromVidis.mockResolvedValueOnce(vidisOfferItems); return { mediaSource, - vidisItemResponses, + vidisOfferItems, + format, }; }; - it('should fetch school activations from vidis and sync them with media school licenses in svs', async () => { - const { mediaSource, vidisItemResponses } = setup(); + it('should find the vidis media source', async () => { + const { format } = setup(); + + await strategy.sync(); + + expect(mediaSourceService.findByFormat).toBeCalledWith(format); + }); + + it('should fetch activated offer items from vidis', async () => { + const { mediaSource } = setup(); + + await strategy.sync(); + + expect(vidisFetchService.getOfferItemsFromVidis).toBeCalledWith(mediaSource); + }); + + it('should sync the activated offer items with media school licenses in svs', async () => { + const { mediaSource, vidisOfferItems } = setup(); + + await strategy.sync(); + + expect(vidisSyncService.syncMediaSchoolLicenses).toBeCalledWith(mediaSource, vidisOfferItems); + }); + }); + + describe('when the vidis media source is not found', () => { + it('should throw an MediaSourceForSyncNotFoundLoggableException', async () => { + mediaSourceService.findByFormat.mockResolvedValueOnce(null); - await vidisSyncStrategy.sync(); + const promise = strategy.sync(); - expect(vidisSyncService.getVidisMediaSource).toBeCalled(); - expect(vidisSyncService.getSchoolActivationsFromVidis).toBeCalledWith(mediaSource); - expect(vidisSyncService.syncMediaSchoolLicenses).toBeCalledWith(mediaSource, vidisItemResponses); + await expect(promise).rejects.toThrow( + new MediaSourceForSyncNotFoundLoggableException(MediaSourceDataFormat.VIDIS) + ); }); }); }); diff --git a/apps/server/src/infra/sync/media-licenses/strategy/vidis-sync.strategy.ts b/apps/server/src/infra/sync/media-licenses/strategy/vidis-sync.strategy.ts index 438d0ab337..0be176d25b 100644 --- a/apps/server/src/infra/sync/media-licenses/strategy/vidis-sync.strategy.ts +++ b/apps/server/src/infra/sync/media-licenses/strategy/vidis-sync.strategy.ts @@ -1,11 +1,17 @@ +import { MediaSource, MediaSourceDataFormat, MediaSourceService } from '@modules/media-source'; +import { MediaSourceForSyncNotFoundLoggableException } from '@modules/media-source/loggable'; import { Injectable } from '@nestjs/common'; import { SyncStrategy } from '../../strategy/sync-strategy'; import { SyncStrategyTarget } from '../../sync-strategy.types'; -import { VidisSyncService } from '../service/vidis-sync.service'; +import { VidisSyncService, VidisFetchService } from '../service'; @Injectable() export class VidisSyncStrategy extends SyncStrategy { - constructor(private readonly vidisSyncService: VidisSyncService) { + constructor( + private readonly vidisSyncService: VidisSyncService, + private readonly vidisFetchService: VidisFetchService, + private readonly mediaSourceService: MediaSourceService + ) { super(); } @@ -14,10 +20,14 @@ export class VidisSyncStrategy extends SyncStrategy { } public async sync(): Promise { - const mediaSource = await this.vidisSyncService.getVidisMediaSource(); + const mediaSource: MediaSource | null = await this.mediaSourceService.findByFormat(MediaSourceDataFormat.VIDIS); - const vidisItems = await this.vidisSyncService.getSchoolActivationsFromVidis(mediaSource); + if (!mediaSource) { + throw new MediaSourceForSyncNotFoundLoggableException(MediaSourceDataFormat.VIDIS); + } - await this.vidisSyncService.syncMediaSchoolLicenses(mediaSource, vidisItems); + const vidisOfferItems = await this.vidisFetchService.getOfferItemsFromVidis(mediaSource); + + await this.vidisSyncService.syncMediaSchoolLicenses(mediaSource, vidisOfferItems); } } diff --git a/apps/server/src/infra/sync/media-licenses/testing/index.ts b/apps/server/src/infra/sync/media-licenses/testing/index.ts index a55c419541..b08355d7f6 100644 --- a/apps/server/src/infra/sync/media-licenses/testing/index.ts +++ b/apps/server/src/infra/sync/media-licenses/testing/index.ts @@ -1,2 +1,2 @@ -export { vidisResponseFactory } from './vidis.response.factory'; -export { vidisItemResponseFactory } from './vidis-item.response.factory'; +export { vidisPageOfferFactory } from './vidis-page-offer.factory'; +export { vidisOfferItemFactory } from './vidis-offer-item.factory'; diff --git a/apps/server/src/infra/sync/media-licenses/testing/vidis-item.response.factory.ts b/apps/server/src/infra/sync/media-licenses/testing/vidis-item.response.factory.ts deleted file mode 100644 index 08241b9b3d..0000000000 --- a/apps/server/src/infra/sync/media-licenses/testing/vidis-item.response.factory.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Factory } from 'fishery'; -import { VidisItemResponse } from '../response'; - -export const vidisItemResponseFactory = Factory.define(({ sequence }) => { - return { - offerId: `${sequence}`, - schoolActivations: ['00100', '00200', '00300'], - educationProviderOrganizationName: 'Test Org', - offerDescription: 'Test Description', - offerLink: 'https://test-link.com/offer', - offerLogo: 'https://test-link.com/offer/logo.svg', - offerTitle: 'VIDIS Test', - offerLongTitle: 'VIDIS Test Response', - offerVersion: 1, - }; -}); diff --git a/apps/server/src/infra/sync/media-licenses/testing/vidis-offer-item.factory.ts b/apps/server/src/infra/sync/media-licenses/testing/vidis-offer-item.factory.ts new file mode 100644 index 0000000000..da6e94c925 --- /dev/null +++ b/apps/server/src/infra/sync/media-licenses/testing/vidis-offer-item.factory.ts @@ -0,0 +1,12 @@ +import { OfferDTO } from '@infra/vidis-client'; +import { Factory } from 'fishery'; + +export const vidisOfferItemFactory = Factory.define(({ sequence }) => { + return { + offerId: sequence, + schoolActivations: ['00100', '00200', '00300'], + offerDescription: 'Test Description', + offerTitle: 'VIDIS Test', + offerVersion: 1, + }; +}); diff --git a/apps/server/src/infra/sync/media-licenses/testing/vidis-page-offer.factory.ts b/apps/server/src/infra/sync/media-licenses/testing/vidis-page-offer.factory.ts new file mode 100644 index 0000000000..94b1115dc4 --- /dev/null +++ b/apps/server/src/infra/sync/media-licenses/testing/vidis-page-offer.factory.ts @@ -0,0 +1,9 @@ +import { Factory } from 'fishery'; +import { PageOfferDTO } from '@infra/vidis-client'; +import { vidisOfferItemFactory } from './vidis-offer-item.factory'; + +export const vidisPageOfferFactory = Factory.define(() => { + return { + items: vidisOfferItemFactory.buildList(3), + }; +}); diff --git a/apps/server/src/infra/sync/media-licenses/testing/vidis.response.factory.ts b/apps/server/src/infra/sync/media-licenses/testing/vidis.response.factory.ts deleted file mode 100644 index eb2f6ec8c9..0000000000 --- a/apps/server/src/infra/sync/media-licenses/testing/vidis.response.factory.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Factory } from 'fishery'; -import { VidisResponse } from '../response'; -import { vidisItemResponseFactory } from './vidis-item.response.factory'; - -export const vidisResponseFactory = Factory.define(() => { - return { - items: vidisItemResponseFactory.buildList(3), - }; -}); diff --git a/apps/server/src/infra/sync/sync.module.ts b/apps/server/src/infra/sync/sync.module.ts index 6248dcc666..cf1ce70c1d 100644 --- a/apps/server/src/infra/sync/sync.module.ts +++ b/apps/server/src/infra/sync/sync.module.ts @@ -3,6 +3,7 @@ import { ConsoleWriterModule } from '@infra/console'; import { RabbitMQWrapperModule } from '@infra/rabbitmq'; import { TspClientModule } from '@infra/tsp-client/tsp-client.module'; import { EncryptionModule } from '@infra/encryption'; +import { VidisClientModule } from '@infra/vidis-client'; import { AccountModule } from '@modules/account'; import { LegacySchoolModule } from '@modules/legacy-school'; import { MediaSourceModule } from '@modules/media-source/media-source.module'; @@ -22,7 +23,7 @@ import { TspSyncService } from './tsp/tsp-sync.service'; import { TspSyncStrategy } from './tsp/tsp-sync.strategy'; import { SyncUc } from './uc/sync.uc'; import { TspFetchService } from './tsp/tsp-fetch.service'; -import { VidisSyncService, VidisSyncStrategy } from './media-licenses'; +import { VidisSyncService, VidisSyncStrategy, VidisFetchService } from './media-licenses'; @Module({ imports: [ @@ -34,6 +35,7 @@ import { VidisSyncService, VidisSyncStrategy } from './media-licenses'; HttpModule, SchoolLicenseModule, EncryptionModule, + VidisClientModule, ...((Configuration.get('FEATURE_TSP_SYNC_ENABLED') as boolean) ? [ TspClientModule, @@ -56,6 +58,7 @@ import { VidisSyncService, VidisSyncStrategy } from './media-licenses'; : []), VidisSyncService, VidisSyncStrategy, + VidisFetchService, ], exports: [SyncConsole], }) diff --git a/apps/server/src/infra/vidis-client/generated/.openapi-generator/FILES b/apps/server/src/infra/vidis-client/generated/.openapi-generator/FILES index 5381aa4321..d1183e6e2b 100644 --- a/apps/server/src/infra/vidis-client/generated/.openapi-generator/FILES +++ b/apps/server/src/infra/vidis-client/generated/.openapi-generator/FILES @@ -1,6 +1,5 @@ .gitignore .npmignore -.openapi-generator-ignore api.ts api/idmbetreiber-api.ts base.ts diff --git a/apps/server/src/infra/vidis-client/generated/api/idmbetreiber-api.ts b/apps/server/src/infra/vidis-client/generated/api/idmbetreiber-api.ts index 2423c2faec..5b95bd1390 100644 --- a/apps/server/src/infra/vidis-client/generated/api/idmbetreiber-api.ts +++ b/apps/server/src/infra/vidis-client/generated/api/idmbetreiber-api.ts @@ -30,18 +30,18 @@ import type { PageOfferDTO } from '../models'; export const IDMBetreiberApiAxiosParamCreator = function (configuration?: Configuration) { return { /** - * List all offers, that has activated by the selected school. - * @param {string} schoolName + * List all offers, that has activated by any schools in the selected region. + * @param {string} regionName * @param {string} [page] * @param {string} [pageSize] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getActivatedOffersBySchool: async (schoolName: string, page?: string, pageSize?: string, options: RawAxiosRequestConfig = {}): Promise => { - // verify required parameter 'schoolName' is not null or undefined - assertParamExists('getActivatedOffersBySchool', 'schoolName', schoolName) - const localVarPath = `/v1.0/offers/activated/by-school/{schoolName}` - .replace(`{${"schoolName"}}`, encodeURIComponent(String(schoolName))); + getActivatedOffersByRegion: async (regionName: string, page?: string, pageSize?: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'regionName' is not null or undefined + assertParamExists('getActivatedOffersByRegion', 'regionName', regionName) + const localVarPath = `/v1.0/offers/activated/by-region/{regionName}` + .replace(`{${"regionName"}}`, encodeURIComponent(String(regionName))); // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -83,17 +83,17 @@ export const IDMBetreiberApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = IDMBetreiberApiAxiosParamCreator(configuration) return { /** - * List all offers, that has activated by the selected school. - * @param {string} schoolName + * List all offers, that has activated by any schools in the selected region. + * @param {string} regionName * @param {string} [page] * @param {string} [pageSize] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getActivatedOffersBySchool(schoolName: string, page?: string, pageSize?: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getActivatedOffersBySchool(schoolName, page, pageSize, options); + async getActivatedOffersByRegion(regionName: string, page?: string, pageSize?: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getActivatedOffersByRegion(regionName, page, pageSize, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['IDMBetreiberApi.getActivatedOffersBySchool']?.[localVarOperationServerIndex]?.url; + const localVarOperationServerBasePath = operationServerMap['IDMBetreiberApi.getActivatedOffersByRegion']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, } @@ -107,15 +107,15 @@ export const IDMBetreiberApiFactory = function (configuration?: Configuration, b const localVarFp = IDMBetreiberApiFp(configuration) return { /** - * List all offers, that has activated by the selected school. - * @param {string} schoolName + * List all offers, that has activated by any schools in the selected region. + * @param {string} regionName * @param {string} [page] * @param {string} [pageSize] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getActivatedOffersBySchool(schoolName: string, page?: string, pageSize?: string, options?: any): AxiosPromise { - return localVarFp.getActivatedOffersBySchool(schoolName, page, pageSize, options).then((request) => request(axios, basePath)); + getActivatedOffersByRegion(regionName: string, page?: string, pageSize?: string, options?: any): AxiosPromise { + return localVarFp.getActivatedOffersByRegion(regionName, page, pageSize, options).then((request) => request(axios, basePath)); }, }; }; @@ -127,15 +127,15 @@ export const IDMBetreiberApiFactory = function (configuration?: Configuration, b */ export interface IDMBetreiberApiInterface { /** - * List all offers, that has activated by the selected school. - * @param {string} schoolName + * List all offers, that has activated by any schools in the selected region. + * @param {string} regionName * @param {string} [page] * @param {string} [pageSize] * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof IDMBetreiberApiInterface */ - getActivatedOffersBySchool(schoolName: string, page?: string, pageSize?: string, options?: RawAxiosRequestConfig): AxiosPromise; + getActivatedOffersByRegion(regionName: string, page?: string, pageSize?: string, options?: RawAxiosRequestConfig): AxiosPromise; } @@ -147,16 +147,16 @@ export interface IDMBetreiberApiInterface { */ export class IDMBetreiberApi extends BaseAPI implements IDMBetreiberApiInterface { /** - * List all offers, that has activated by the selected school. - * @param {string} schoolName + * List all offers, that has activated by any schools in the selected region. + * @param {string} regionName * @param {string} [page] * @param {string} [pageSize] * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof IDMBetreiberApi */ - public getActivatedOffersBySchool(schoolName: string, page?: string, pageSize?: string, options?: RawAxiosRequestConfig) { - return IDMBetreiberApiFp(this.configuration).getActivatedOffersBySchool(schoolName, page, pageSize, options).then((request) => request(this.axios, this.basePath)); + public getActivatedOffersByRegion(regionName: string, page?: string, pageSize?: string, options?: RawAxiosRequestConfig) { + return IDMBetreiberApiFp(this.configuration).getActivatedOffersByRegion(regionName, page, pageSize, options).then((request) => request(this.axios, this.basePath)); } } diff --git a/apps/server/src/infra/vidis-client/vidis-client-factory.integration.spec.ts b/apps/server/src/infra/vidis-client/vidis-client-factory.integration.spec.ts deleted file mode 100644 index ddf7e960e0..0000000000 --- a/apps/server/src/infra/vidis-client/vidis-client-factory.integration.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { createMock } from '@golevelup/ts-jest'; -import { ServerTestModule } from '@modules/server'; -import { ConfigService } from '@nestjs/config'; -import { Test, TestingModule } from '@nestjs/testing'; -import { VidisClientFactory } from './vidis-client-factory'; -import { VidisClientModule } from './vidis-client.module'; - -describe.skip('VidisClientFactory Integration', () => { - let module: TestingModule; - let sut: VidisClientFactory; - - beforeAll(async () => { - module = await Test.createTestingModule({ - imports: [ServerTestModule, VidisClientModule], - }) - .overrideProvider(ConfigService) - .useValue( - createMock({ - getOrThrow: (key: string) => { - switch (key) { - case 'VIDIS_API_CLIENT_BASE_URL': - return 'https://test2.schulportal-thueringen.de/tip-ms/api'; - - default: - throw new Error(`Unknown key: ${key}`); - } - }, - }) - ) - .compile(); - - sut = module.get(VidisClientFactory); - }); - - afterAll(async () => { - await module.close(); - }); - - it('should be defined', () => { - expect(sut).toBeDefined(); - }); -}); diff --git a/apps/server/src/infra/vidis-client/vidis-client-factory.spec.ts b/apps/server/src/infra/vidis-client/vidis-client-factory.spec.ts index 2524c063d4..3b02bfc36b 100644 --- a/apps/server/src/infra/vidis-client/vidis-client-factory.spec.ts +++ b/apps/server/src/infra/vidis-client/vidis-client-factory.spec.ts @@ -3,12 +3,13 @@ import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { ServerConfig } from '@modules/server'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; +import { VidisClientConfig } from './vidis-client-config'; import { VidisClientFactory } from './vidis-client-factory'; -describe('TspClientFactory', () => { +describe(VidisClientFactory.name, () => { let module: TestingModule; - let sut: VidisClientFactory; - let configServiceMock: DeepMocked>; + let factory: VidisClientFactory; + let configService: DeepMocked>; beforeAll(async () => { module = await Test.createTestingModule({ @@ -16,22 +17,15 @@ describe('TspClientFactory', () => { VidisClientFactory, { provide: ConfigService, - useValue: createMock>({ - getOrThrow: (key: string) => { - switch (key) { - case 'VIDIS_API_CLIENT_BASE_URL': - return faker.internet.url(); - default: - throw new Error(`Unknown key: ${key}`); - } - }, - }), + useValue: createMock>(), }, ], }).compile(); - sut = module.get(VidisClientFactory); - configServiceMock = module.get(ConfigService); + factory = module.get(VidisClientFactory); + configService = module.get(ConfigService); + + configService.getOrThrow.mockReturnValueOnce(faker.internet.url()); }); afterAll(async () => { @@ -39,25 +33,34 @@ describe('TspClientFactory', () => { }); beforeEach(() => { - jest.resetAllMocks(); - jest.restoreAllMocks(); jest.clearAllMocks(); }); it('should be defined', () => { - expect(sut).toBeDefined(); + expect(factory).toBeDefined(); }); - describe('createExportClient', () => { - describe('when createExportClient is called', () => { - it('should return ExportApiInterface', () => { - const result = sut.createExportClient({ - password: faker.string.alpha(), - username: faker.string.alpha(), + describe('createVidisClient', () => { + describe('when the function is called', () => { + const setup = () => { + const username = faker.string.alpha(); + const password = faker.string.alpha(); + + return { + username, + password, + }; + }; + + it('should return a vidis api client as an IDMBetreiberApiInterface', () => { + const { username, password } = setup(); + + const result = factory.createVidisClient({ + username, + password, }); expect(result).toBeDefined(); - expect(configServiceMock.getOrThrow).toHaveBeenCalledTimes(0); }); }); }); diff --git a/apps/server/src/infra/vidis-client/vidis-client-factory.ts b/apps/server/src/infra/vidis-client/vidis-client-factory.ts index d3dd07db29..16a9825a26 100644 --- a/apps/server/src/infra/vidis-client/vidis-client-factory.ts +++ b/apps/server/src/infra/vidis-client/vidis-client-factory.ts @@ -3,24 +3,17 @@ import { ConfigService } from '@nestjs/config'; import { Configuration, IDMBetreiberApiFactory, IDMBetreiberApiInterface } from './generated'; import { VidisClientConfig } from './vidis-client-config'; -type FactoryParams = { - username: string; - password: string; -}; - @Injectable() export class VidisClientFactory { private readonly baseUrl: string; constructor(private readonly configService: ConfigService) { - this.baseUrl = configService.getOrThrow('VIDIS_API_CLIENT_BASE_URL'); + this.baseUrl = this.configService.getOrThrow('VIDIS_API_CLIENT_BASE_URL'); } - public createExportClient(params: FactoryParams): IDMBetreiberApiInterface { + public createVidisClient(): IDMBetreiberApiInterface { const factory = IDMBetreiberApiFactory( new Configuration({ - username: params.username, - password: params.password, basePath: this.baseUrl, }) ); diff --git a/apps/server/src/modules/server/server.config.ts b/apps/server/src/modules/server/server.config.ts index 859e15a551..8e1763263d 100644 --- a/apps/server/src/modules/server/server.config.ts +++ b/apps/server/src/modules/server/server.config.ts @@ -32,6 +32,7 @@ import { SchulcloudTheme } from '@shared/domain/types'; import type { CoreModuleConfig } from '@src/core'; import { Algorithm } from 'jsonwebtoken'; import type { Timezone } from './types/timezone.enum'; +import { VidisClientConfig } from '@infra/vidis-client'; export enum NodeEnvType { TEST = 'test', @@ -73,7 +74,8 @@ export interface ServerConfig AlertConfig, ShdConfig, OauthConfig, - EncryptionConfig { + EncryptionConfig, + VidisClientConfig { NODE_ENV: NodeEnvType; SC_DOMAIN: string; HOST: string; @@ -321,6 +323,7 @@ const config: ServerConfig = { FEATURE_OAUTH_LOGIN: Configuration.get('FEATURE_OAUTH_LOGIN') as boolean, FEATURE_EXTERNAL_SYSTEM_LOGOUT_ENABLED: Configuration.get('FEATURE_EXTERNAL_SYSTEM_LOGOUT_ENABLED') as boolean, PUBLIC_BACKEND_URL: Configuration.get('PUBLIC_BACKEND_URL') as string, + VIDIS_API_CLIENT_BASE_URL: Configuration.get('VIDIS_API_CLIENT_BASE_URL') as string, }; export const serverConfig = () => config; diff --git a/config/default.schema.json b/config/default.schema.json index 196edcfc6b..a1b31d058e 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1675,6 +1675,11 @@ "type": "boolean", "default": "false", "description": "Enables the external system logout feature" + }, + "VIDIS_API_CLIENT_BASE_URL": { + "type": "string", + "default": "https://service-stage.vidis.schule/o/vidis-rest", + "description": "The VIDIS API base URL" } }, "required": [] diff --git a/openapitools.json b/openapitools.json index 640cf98142..3666489e34 100644 --- a/openapitools.json +++ b/openapitools.json @@ -66,7 +66,7 @@ "skipValidateSpec": true, "enablePostProcessFile": true, "openapiNormalizer": { - "FILTER": "operationId:getActivatedOffersBySchool" + "FILTER": "operationId:getActivatedOffersByRegion" }, "additionalProperties": { "apiPackage": "api",