Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

N21-1800 media metadata sync logic #5487

Open
wants to merge 49 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
5debfec
N21-1800 add bilo media source for db init
GordonNicholasCap Jan 30, 2025
cfc55e0
N21-1800 move not found loggable to media-source module
GordonNicholasCap Jan 30, 2025
844f031
N21-1800 wip sync strategy
GordonNicholasCap Jan 30, 2025
c656327
N21-1800 wip sync strategy
GordonNicholasCap Jan 31, 2025
e57763d
N21-1800 add modifiedMetadata field to external tool
GordonNicholasCap Jan 31, 2025
3091c0e
N21-1800 wip sync logic
GordonNicholasCap Feb 4, 2025
4197512
Merge branch 'main' into N21-1800-media-metadata-sync-logic
GordonNicholasCap Feb 4, 2025
ece341d
add test for findAllByMediaSource
sdinkov Feb 4, 2025
de84e74
add test for findExternalToolsByMediaSource
sdinkov Feb 4, 2025
dc694c5
N21-1800 oauth config not found loggable, factory for reports
GordonNicholasCap Feb 4, 2025
d7ebaed
N21-1800 move oauth adapter into its own module
GordonNicholasCap Feb 5, 2025
f74e40b
N21-1800 fix fetch service, adjust bilo media source db init
GordonNicholasCap Feb 5, 2025
263de84
N21-1800 adjust db init test
GordonNicholasCap Feb 5, 2025
c376cc6
add bilo media fetch service tests + update factory
sdinkov Feb 5, 2025
b998475
update vidis sync strategy test
sdinkov Feb 5, 2025
9e4a636
N21-1800 fix & add tests to report factory
GordonNicholasCap Feb 5, 2025
cb617eb
N21-1800 register bilo strategy, adapt sync service, add test to bilo…
GordonNicholasCap Feb 5, 2025
39702d9
N21-1800 restructure media source sync service & strategy, wip tests
GordonNicholasCap Feb 6, 2025
f14995b
N21-1800 fix dep cycle, wip test
GordonNicholasCap Feb 6, 2025
23bccca
N21-1800 minor refactoring
GordonNicholasCap Feb 6, 2025
e331fc1
N21-1800 add base url to oauth config
GordonNicholasCap Feb 6, 2025
7455ead
N21-1800 move media source sync service & strategy out of media sourc…
GordonNicholasCap Feb 7, 2025
05a918b
N21-1800 move & adapt bilo fetch for base url
GordonNicholasCap Feb 7, 2025
3ff90c9
N21-1800 fix & minor adjustments to bilo strategy
GordonNicholasCap Feb 7, 2025
09692d8
N21-1800 bilo client
GordonNicholasCap Feb 7, 2025
33ed1dc
Merge branch 'refs/heads/main' into N21-1800-media-metadata-sync-logic
GordonNicholasCap Feb 7, 2025
7976158
Merge branch 'refs/heads/main' into N21-1800-media-metadata-sync-logic
GordonNicholasCap Feb 7, 2025
d44763c
N21-1800 fix import errors
GordonNicholasCap Feb 10, 2025
99cd125
N21-1800 fix tool dep cycle
GordonNicholasCap Feb 10, 2025
3e3c569
N21-1800 minor adjustments & use batch save operation
GordonNicholasCap Feb 10, 2025
f4d0207
N21-1800 fix failing tests
GordonNicholasCap Feb 10, 2025
17cd520
N21-1800 fix test
GordonNicholasCap Feb 10, 2025
5f0b9cb
N21-1800 fix & adjust imports
GordonNicholasCap Feb 12, 2025
91e5102
N21-1800 update bilo client adapter
GordonNicholasCap Feb 12, 2025
209687b
N21-1800 fix sync strategy (datetime conversion & bilo client)
GordonNicholasCap Feb 12, 2025
f8575a6
Merge branch 'refs/heads/main' into N21-1800-media-metadata-sync-logic
GordonNicholasCap Feb 12, 2025
4947973
N21-1800 add debug logging for sync
GordonNicholasCap Feb 12, 2025
cb1b3f3
N21-1800 attempt to fix error in management console
GordonNicholasCap Feb 12, 2025
6184cdf
N21-1800 fix eslint
GordonNicholasCap Feb 12, 2025
4dff959
N21-1800 revert fix
GordonNicholasCap Feb 12, 2025
9ab5147
N21-1800 fix undefined modified date, adjust update condition & logs
GordonNicholasCap Feb 13, 2025
a5d11ed
Merge branch 'main' into N21-1800-media-metadata-sync-logic
GordonNicholasCap Feb 13, 2025
b476c33
N21-1800 attempt to fix db seed error with feature flag
GordonNicholasCap Feb 13, 2025
5168e30
Merge branch 'main' into N21-1800-media-metadata-sync-logic
GordonNicholasCap Feb 13, 2025
2b53ab9
N21-1800 fix wrong logo url mapping
GordonNicholasCap Feb 13, 2025
49eabd1
N21-1800 fix missing feature flag for sync configmap
GordonNicholasCap Feb 14, 2025
f1d97e2
N21-1800 fix missing default for configmap
GordonNicholasCap Feb 14, 2025
19773e2
Merge remote-tracking branch 'refs/remotes/origin/main' into N21-1800…
GordonNicholasCap Feb 18, 2025
40ec237
Merge branch 'refs/heads/main' into N21-1800-media-metadata-sync-logic
GordonNicholasCap Feb 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ansible/roles/media-licenses/defaults/main.yml
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
SERVER_VIDIS_SYNC_CRONJOB_SCHEDULE: "20 4 * * *"
FEATURE_MEDIA_METADATA_SYNC_ENABLED: "false"
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ data:
NODE_OPTIONS: "--max-old-space-size=1536"
NEST_LOG_LEVEL: "info"
EXIT_ON_ERROR: "true"
FEATURE_MEDIA_METADATA_SYNC_ENABLED: "{{ FEATURE_MEDIA_METADATA_SYNC_ENABLED }}"
13 changes: 13 additions & 0 deletions apps/server/src/infra/bilo-client/bilo-client.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { EncryptionModule } from '@infra/encryption';
import { LoggerModule } from '@core/logger';
import { OauthAdapterModule } from '@modules/oauth-adapter';
import { BiloMediaClientAdapter } from './bilo-media-client.adapter';

@Module({
imports: [HttpModule, EncryptionModule, LoggerModule, OauthAdapterModule],
providers: [BiloMediaClientAdapter],
exports: [BiloMediaClientAdapter],
})
export class BiloClientModule {}
375 changes: 375 additions & 0 deletions apps/server/src/infra/bilo-client/bilo-media-client.adapter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,375 @@
import { createMock, DeepMocked } from '@golevelup/ts-jest';
import { HttpService } from '@nestjs/axios';
import { Test, TestingModule } from '@nestjs/testing';
import { Logger } from '@core/logger';
import { AxiosErrorLoggable } from '@core/error/loggable';
import { axiosResponseFactory } from '@testing/factory/axios-response.factory';
import { axiosErrorFactory } from '@testing/factory/axios-error.factory';
import { DefaultEncryptionService, EncryptionService, SymmetricKeyEncryptionService } from '@infra/encryption';
import {
MediaSource,
MediaSourceDataFormat,
MediaSourceOauthConfig,
MediaSourceOauthConfigNotFoundLoggableException,
} from '@modules/media-source';
import { mediaSourceFactory } from '@modules/media-source/testing';
import {
ClientCredentialsGrantTokenRequest,
OauthAdapterService,
OAuthGrantType,
OAuthTokenDto,
} from '@modules/oauth-adapter';
import { AxiosResponse } from 'axios';
import { ValidationError } from 'class-validator';
import { of, throwError } from 'rxjs';
import { MediaQueryBadResponseReport } from './interface';
import { BiloMediaQueryBadResponseLoggable } from './loggable';
import { BiloMediaQueryBodyParams } from './request';
import { BiloLinkResponse, BiloMediaQueryDataResponse, BiloMediaQueryResponse } from './response';
import { biloMediaQueryResponseFactory, biloMediaQueryDataResponseFactory } from './testing';
import { BiloMediaClientAdapter } from './bilo-media-client.adapter';

describe(BiloMediaClientAdapter.name, () => {
let module: TestingModule;
let service: BiloMediaClientAdapter;

let httpService: DeepMocked<HttpService>;
let logger: DeepMocked<Logger>;
let oauthAdapterService: DeepMocked<OauthAdapterService>;
let encryptionService: DeepMocked<SymmetricKeyEncryptionService>;

beforeEach(async () => {
module = await Test.createTestingModule({
providers: [
BiloMediaClientAdapter,
{
provide: HttpService,
useValue: createMock<HttpService>(),
},
{
provide: Logger,
useValue: createMock<Logger>(),
},
{
provide: OauthAdapterService,
useValue: createMock<OauthAdapterService>(),
},
{
provide: DefaultEncryptionService,
useValue: createMock<EncryptionService>(),
},
],
}).compile();

service = module.get(BiloMediaClientAdapter);
httpService = module.get(HttpService);
oauthAdapterService = module.get(OauthAdapterService);
encryptionService = module.get(DefaultEncryptionService);
logger = module.get(Logger);
});

afterAll(async () => {
await module.close();
});

afterEach(() => {
jest.resetAllMocks();
});

it('should be defined', () => {
expect(service).toBeDefined();
});

describe('fetchMediaMetadata', () => {
describe('when the metadata are fetched successfully', () => {
const setup = () => {
const mediaSource: MediaSource = mediaSourceFactory.withBildungslogin().build();

const mockToken = new OAuthTokenDto({
accessToken: 'mock-access-token',
Dismissed Show dismissed Hide dismissed
idToken: 'mock-id-token',
refreshToken: 'mock-refresh-token',
});

const mockResponseData: BiloMediaQueryResponse[] = biloMediaQueryResponseFactory.buildList(2);
const mediumIds = mockResponseData.map((response: BiloMediaQueryResponse) => response.query.id);

const mockAxiosResponse = axiosResponseFactory.build({
data: mockResponseData,
}) as AxiosResponse<BiloMediaQueryResponse[]>;

const decryptedClientSecret = 'client-secret-decrypted';

jest.spyOn(oauthAdapterService, 'sendTokenRequest').mockResolvedValueOnce(mockToken);
jest.spyOn(httpService, 'post').mockReturnValueOnce(of(mockAxiosResponse));
jest.spyOn(encryptionService, 'decrypt').mockReturnValueOnce(decryptedClientSecret);

const expectedBiloRequestBody = mediumIds.map((id: string) => ({ id } as BiloMediaQueryBodyParams));

const expectedMetadataItems = mockResponseData.map((responseData: BiloMediaQueryResponse) => responseData.data);

return {
mediumIds,
mediaSource,
mockToken,
decryptedClientSecret,
expectedBiloRequestBody,
expectedMetadataItems,
};
};

it('should decrypt the oauth client secret', async () => {
const { mediumIds, mediaSource } = setup();

await service.fetchMediaMetadata(mediumIds, mediaSource);

const oauthConfig = mediaSource.oauthConfig as MediaSourceOauthConfig;
expect(encryptionService.decrypt).toHaveBeenCalledWith(oauthConfig.clientSecret);
});

it('should call oauth adapter to fetch the oauth token', async () => {
const { mediumIds, mediaSource, decryptedClientSecret } = setup();

await service.fetchMediaMetadata(mediumIds, mediaSource);

const oauthConfig = mediaSource.oauthConfig as MediaSourceOauthConfig;
expect(oauthAdapterService.sendTokenRequest).toHaveBeenCalledWith(oauthConfig.authEndpoint, {
client_id: oauthConfig.clientId,
client_secret: decryptedClientSecret,
grant_type: OAuthGrantType.CLIENT_CREDENTIALS_GRANT,
} as ClientCredentialsGrantTokenRequest);
});

it('should call http service with the correct params and headers', async () => {
const { mediumIds, mediaSource, expectedBiloRequestBody, mockToken } = setup();

await service.fetchMediaMetadata(mediumIds, mediaSource);

const oauthConfig = mediaSource.oauthConfig as MediaSourceOauthConfig;
expect(httpService.post).toHaveBeenCalledWith(
`${oauthConfig.baseUrl}/query`,
expect.arrayContaining(expectedBiloRequestBody),
expect.objectContaining({
headers: {
Authorization: `Bearer ${mockToken.accessToken}`,
'Content-Type': 'application/vnd.de.bildungslogin.mediaquery+json',
},
})
);
});

it('should return a list of bilo media query response', async () => {
const { mediumIds, mediaSource, expectedMetadataItems } = setup();

const result = await service.fetchMediaMetadata(mediumIds, mediaSource);

expect(result).toEqual(expect.arrayContaining(expectedMetadataItems));
});
});

describe('when the metadata fetched have bad status', () => {
const setup = () => {
const mediaSource: MediaSource = mediaSourceFactory.withBildungslogin().build();

const mockToken = new OAuthTokenDto({
accessToken: 'mock-access-token',
idToken: 'mock-id-token',
refreshToken: 'mock-refresh-token',
});

const validResponses: BiloMediaQueryResponse[] = biloMediaQueryResponseFactory.buildList(2);
const badStatusResponses: BiloMediaQueryResponse[] = biloMediaQueryResponseFactory.buildList(2, {
status: 404,
});
const responses = [...validResponses, ...badStatusResponses];

const mediumIds = responses.map((response: BiloMediaQueryResponse) => response.query.id);

const mockAxiosResponse = axiosResponseFactory.build({
data: responses,
}) as AxiosResponse<BiloMediaQueryResponse[]>;

const decryptedClientSecret = 'client-secret-decrypted';

jest.spyOn(oauthAdapterService, 'sendTokenRequest').mockResolvedValueOnce(mockToken);
jest.spyOn(httpService, 'post').mockReturnValueOnce(of(mockAxiosResponse));
jest.spyOn(encryptionService, 'decrypt').mockReturnValueOnce(decryptedClientSecret);

const expectedMetadataItems = validResponses.map((response: BiloMediaQueryResponse) => response.data);

const expectedBadStatusReports = badStatusResponses.map((response: BiloMediaQueryResponse) => {
const report: MediaQueryBadResponseReport = {
mediumId: response.query.id,
status: response.status,
validationErrors: [],
};

return report;
});

return {
mediumIds,
mediaSource,
mockToken,
expectedMetadataItems,
expectedBadStatusReports,
};
};

it('should return media metadata only from responses with valid status', async () => {
const { mediumIds, mediaSource, expectedMetadataItems } = setup();

const result = await service.fetchMediaMetadata(mediumIds, mediaSource);

expect(result).toEqual(expect.arrayContaining(expectedMetadataItems));
});

it('should log the responses with bad status as debug log', async () => {
const { mediumIds, mediaSource, expectedBadStatusReports } = setup();

await service.fetchMediaMetadata(mediumIds, mediaSource);

expect(logger.debug).toHaveBeenCalledWith(new BiloMediaQueryBadResponseLoggable(expectedBadStatusReports));
});
});

describe('when the metadata fetched have validation errors', () => {
const setup = () => {
const mediaSource: MediaSource = mediaSourceFactory.withBildungslogin().build();

const mockToken = new OAuthTokenDto({
accessToken: 'mock-access-token',
idToken: 'mock-id-token',
refreshToken: 'mock-refresh-token',
});

const validResponses: BiloMediaQueryResponse[] = biloMediaQueryResponseFactory.buildList(2);

const invalidMetadataItems: BiloMediaQueryDataResponse[] = biloMediaQueryDataResponseFactory.buildList(2, {
title: undefined,
modified: undefined,
cover: { href: 'test', rel: 'src' } as BiloLinkResponse,
});

const badResponses: BiloMediaQueryResponse[] = invalidMetadataItems.map((data: BiloMediaQueryDataResponse) =>
biloMediaQueryResponseFactory.build({
query: { id: data.id } as BiloMediaQueryBodyParams,
status: 200,
data,
})
);

const responses = [...validResponses, ...badResponses];

const mediumIds = responses.map((response: BiloMediaQueryResponse) => response.query.id);

const mockAxiosResponse = axiosResponseFactory.build({
data: responses,
}) as AxiosResponse<BiloMediaQueryResponse[]>;

const decryptedClientSecret = 'client-secret-decrypted';

jest.spyOn(oauthAdapterService, 'sendTokenRequest').mockResolvedValueOnce(mockToken);
jest.spyOn(httpService, 'post').mockReturnValueOnce(of(mockAxiosResponse));
jest.spyOn(encryptionService, 'decrypt').mockReturnValueOnce(decryptedClientSecret);

const expectedMetadataItems = validResponses.map((response: BiloMediaQueryResponse) => response.data);

const expectedBadResponseReports = badResponses.map((response: BiloMediaQueryResponse) => {
const report: MediaQueryBadResponseReport = {
mediumId: response.query.id,
status: response.status,
validationErrors: expect.any(Array) as ValidationError[],
};

return report;
});

return {
mediumIds,
mediaSource,
mockToken,
expectedMetadataItems,
expectedBadResponseReports,
};
};

it('should return media metadata only from valid responses', async () => {
const { mediumIds, mediaSource, expectedMetadataItems } = setup();

const result = await service.fetchMediaMetadata(mediumIds, mediaSource);

expect(result).toEqual(expect.arrayContaining(expectedMetadataItems));
});

it('should log the bad responses as debug log', async () => {
const { mediumIds, mediaSource, expectedBadResponseReports } = setup();

await service.fetchMediaMetadata(mediumIds, mediaSource);

expect(logger.debug).toHaveBeenCalledWith(new BiloMediaQueryBadResponseLoggable(expectedBadResponseReports));
});
});

describe('when the oauth config of the media source is missing', () => {
const setup = () => {
const mediumIds = ['123'];
const mediaSource: MediaSource = mediaSourceFactory.build({ oauthConfig: undefined });

return { mediumIds, mediaSource };
};

it('should throw an MediaSourceOauthConfigNotFoundLoggableException', async () => {
const { mediumIds, mediaSource } = setup();

const promise = service.fetchMediaMetadata(mediumIds, mediaSource);

await expect(promise).rejects.toThrow(
new MediaSourceOauthConfigNotFoundLoggableException(mediaSource.id, MediaSourceDataFormat.BILDUNGSLOGIN)
);
});
});

describe('when an axios error is thrown while fetching the metadata', () => {
const setup = () => {
const mediumIds = ['123'];
const mediaSource: MediaSource = mediaSourceFactory.withBildungslogin().build();

const axiosError = axiosErrorFactory.build();

httpService.post.mockReturnValueOnce(throwError(() => axiosError));

return { mediumIds, mediaSource, axiosError };
};

it('should throw an AxiosErrorLoggable', async () => {
const { mediumIds, mediaSource, axiosError } = setup();

const promise = service.fetchMediaMetadata(mediumIds, mediaSource);

await expect(promise).rejects.toThrow(new AxiosErrorLoggable(axiosError, 'BILO_GET_MEDIA_METADATA_FAILED'));
});
});

describe('when an unknown error is thrown while fetching the metadata', () => {
const setup = () => {
const mediumIds = ['123'];
const mediaSource: MediaSource = mediaSourceFactory.withBildungslogin().build();

const unknownError = new Error();

httpService.post.mockReturnValueOnce(throwError(() => unknownError));

return { mediumIds, mediaSource, unknownError };
};

it('should throw the unknown error', async () => {
const { mediumIds, mediaSource, unknownError } = setup();

const promise = service.fetchMediaMetadata(mediumIds, mediaSource);

await expect(promise).rejects.toThrow(unknownError);
});
});
});
});
Loading
Loading