diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index 2909e518efe..2ac22d7b42e 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -12,7 +12,7 @@ on: env: MONGODB_VERSION: 6.0 - NODE_VERSION: '20' + NODE_VERSION: '22' jobs: migration: runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c6508edb8a0..602c75ad958 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ permissions: env: MONGODB_VERSION: 6.0 - NODE_VERSION: '20' + NODE_VERSION: '22' jobs: feathers_tests_cov: runs-on: ubuntu-latest diff --git a/.nvmrc b/.nvmrc index 209e3ef4b62..2bd5a0a98a3 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20 +22 diff --git a/Dockerfile b/Dockerfile index 9871502c55a..98a1f7fb8f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ -FROM docker.io/node:20 AS git +FROM docker.io/node:22 AS git RUN mkdir /app && chown -R node:node /app WORKDIR /app COPY .git . RUN git config --global --add safe.directory /app && echo "{\"sha\": \"$(git rev-parse HEAD)\", \"version\": \"$(git describe --tags --abbrev=0)\", \"commitDate\": \"$(git log -1 --format=%cd --date=format:'%Y-%m-%dT%H:%M:%SZ')\", \"birthdate\": \"$(date +%Y-%m-%dT%H:%M:%SZ)\"}" > /app/serverversion -FROM docker.io/node:20-alpine +FROM docker.io/node:22-alpine ENV TZ=Europe/Berlin RUN apk add --no-cache git make python3 # to run ldap sync as script curl is needed diff --git a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 index 15b976ab8da..0618bf19269 100644 --- a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 +++ b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 @@ -12,7 +12,7 @@ data: git clone https://github.com/hpi-schul-cloud/schulcloud-server.git cd /schulcloud-server git checkout {{ SCHULCLOUD_SERVER_IMAGE_TAG }} - npm install + npm ci until mongosh $DATABASE__URL --eval "print(\"waited for connection\")" do sleep 1 @@ -519,43 +519,51 @@ data: # ========== Start of the CTL seed data configuration section. echo "Inserting ctl seed data secrets to external-tools..." + if [ -n "$CTL_SEED_SECRET_ONLINE_DIA_MATHE" ]; then # Encrypt secrets of external tools that contain an lti11 config. - CTL_SEED_SECRET_ONLINE_DIA_MATHE=$(node scripts/secret.js -s $AES_KEY -e $CTL_SEED_SECRET_ONLINE_DIA_MATHE) - CTL_SEED_SECRET_ONLINE_DIA_DEUTSCH=$(node scripts/secret.js -s $AES_KEY -e $CTL_SEED_SECRET_ONLINE_DIA_DEUTSCH) - - mongosh $DATABASE__URL --quiet --eval 'db.getCollection("external-tools").updateOne( - { - "name": "Product Test Onlinediagnose Grundschule - Mathematik", - }, - { $set: { - "config_secret": "'$CTL_SEED_SECRET_ONLINE_DIA_MATHE'", - } }, - { - "upsert": true - } - );' - mongosh $DATABASE__URL --quiet --eval 'db.getCollection("external-tools").updateOne( - { - "name": "Product Test Onlinediagnose Grundschule - Deutsch", - }, - { $set: { - "config_secret": "'$CTL_SEED_SECRET_ONLINE_DIA_DEUTSCH'", - } }, - { - "upsert": true - } - );' - mongosh $DATABASE__URL --quiet --eval 'db.getCollection("external-tools").updateOne( - { - "name": "Merlin Bibliothek", - }, - { $set: { - "config_secret": "'$CTL_SEED_SECRET_MERLIN'", - } }, - { - "upsert": true - } - );' + CTL_SEED_SECRET_ONLINE_DIA_MATHE=$(node scripts/secret.js -s $AES_KEY -e $CTL_SEED_SECRET_ONLINE_DIA_MATHE) + mongosh $DATABASE__URL --quiet --eval 'db.getCollection("external-tools").updateOne( + { + "name": "Product Test Onlinediagnose Grundschule - Mathematik", + }, + { $set: { + "config_secret": "'$CTL_SEED_SECRET_ONLINE_DIA_MATHE'", + } }, + { + "upsert": true + } + );' + fi + if [ -n "$CTL_SEED_SECRET_ONLINE_DIA_DEUTSCH" ]; then + # Encrypt secrets of external tools that contain an lti11 config. + CTL_SEED_SECRET_ONLINE_DIA_DEUTSCH=$(node scripts/secret.js -s $AES_KEY -e $CTL_SEED_SECRET_ONLINE_DIA_DEUTSCH) + mongosh $DATABASE__URL --quiet --eval 'db.getCollection("external-tools").updateOne( + { + "name": "Product Test Onlinediagnose Grundschule - Deutsch", + }, + { $set: { + "config_secret": "'$CTL_SEED_SECRET_ONLINE_DIA_DEUTSCH'", + } }, + { + "upsert": true + } + );' + fi + if [ -n "$CTL_SEED_SECRET_MERLIN" ]; then + # Encrypt secrets of external tools that contain an lti11 config. + CTL_SEED_SECRET_MERLIN=$(node scripts/secret.js -s $AES_KEY -e $CTL_SEED_SECRET_MERLIN) + mongosh $DATABASE__URL --quiet --eval 'db.getCollection("external-tools").updateOne( + { + "name": "Merlin Bibliothek", + }, + { $set: { + "config_secret": "'$CTL_SEED_SECRET_MERLIN'", + } }, + { + "upsert": true + } + );' + fi echo "Inserted ctl seed data secrets to external-tools." # ========== End of the CTL seed data configuration section. diff --git a/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-init.yml.j2 b/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-init.yml.j2 index 3ec8144a7d8..6c126f8b981 100644 --- a/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-init.yml.j2 +++ b/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-init.yml.j2 @@ -50,5 +50,5 @@ spec: requests: cpu: {{ API_CPU_REQUESTS|default("100m", true) }} memory: {{ API_MEMORY_REQUESTS|default("150Mi", true) }} - restartPolicy: Never + restartPolicy: OnFailure backoffLimit: 5 diff --git a/apps/server/src/infra/sync/sync.module.ts b/apps/server/src/infra/sync/sync.module.ts index 04e3fbca432..0bf0c2dc0ec 100644 --- a/apps/server/src/infra/sync/sync.module.ts +++ b/apps/server/src/infra/sync/sync.module.ts @@ -12,10 +12,12 @@ import { LoggerModule } from '@src/core/logger'; import { ProvisioningModule } from '@src/modules/provisioning'; import { SyncConsole } from './console/sync.console'; import { SyncService } from './service/sync.service'; +import { TspLegacyMigrationService } from './tsp/tsp-legacy-migration.service'; import { TspOauthDataMapper } from './tsp/tsp-oauth-data.mapper'; 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'; @Module({ imports: [ @@ -39,7 +41,7 @@ import { SyncUc } from './uc/sync.uc'; SyncUc, SyncService, ...((Configuration.get('FEATURE_TSP_SYNC_ENABLED') as boolean) - ? [TspSyncStrategy, TspSyncService, TspOauthDataMapper] + ? [TspSyncStrategy, TspSyncService, TspOauthDataMapper, TspFetchService, TspLegacyMigrationService] : []), ], exports: [SyncConsole], diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-migration-start.loggable.spec.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-migration-start.loggable.spec.ts new file mode 100644 index 00000000000..ccba74ee0f8 --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-migration-start.loggable.spec.ts @@ -0,0 +1,23 @@ +import { TspLegacyMigrationStartLoggable } from './tsp-legacy-migration-start.loggable'; + +describe(TspLegacyMigrationStartLoggable.name, () => { + let loggable: TspLegacyMigrationStartLoggable; + + beforeAll(() => { + loggable = new TspLegacyMigrationStartLoggable(); + }); + + describe('when loggable is initialized', () => { + it('should be defined', () => { + expect(loggable).toBeDefined(); + }); + }); + + describe('getLogMessage', () => { + it('should return a log message', () => { + expect(loggable.getLogMessage()).toEqual({ + message: 'Running migration of legacy tsp data.', + }); + }); + }); +}); diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-migration-start.loggable.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-migration-start.loggable.ts new file mode 100644 index 00000000000..c3af20dc0c7 --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-migration-start.loggable.ts @@ -0,0 +1,11 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class TspLegacyMigrationStartLoggable implements Loggable { + getLogMessage(): LogMessage { + const message: LogMessage = { + message: 'Running migration of legacy tsp data.', + }; + + return message; + } +} diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-migration-system-missing.loggable.spec.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-migration-system-missing.loggable.spec.ts new file mode 100644 index 00000000000..fe7dc8eed89 --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-migration-system-missing.loggable.spec.ts @@ -0,0 +1,23 @@ +import { TspLegacyMigrationSystemMissingLoggable } from './tsp-legacy-migration-system-missing.loggable'; + +describe(TspLegacyMigrationSystemMissingLoggable.name, () => { + let loggable: TspLegacyMigrationSystemMissingLoggable; + + beforeAll(() => { + loggable = new TspLegacyMigrationSystemMissingLoggable(); + }); + + describe('when loggable is initialized', () => { + it('should be defined', () => { + expect(loggable).toBeDefined(); + }); + }); + + describe('getLogMessage', () => { + it('should return a log message', () => { + expect(loggable.getLogMessage()).toEqual({ + message: 'No legacy system found', + }); + }); + }); +}); diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-migration-system-missing.loggable.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-migration-system-missing.loggable.ts new file mode 100644 index 00000000000..fcdf3b26d0a --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-migration-system-missing.loggable.ts @@ -0,0 +1,11 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class TspLegacyMigrationSystemMissingLoggable implements Loggable { + getLogMessage(): LogMessage { + const message: LogMessage = { + message: 'No legacy system found', + }; + + return message; + } +} diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-school-migration-count.loggable.spec.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-school-migration-count.loggable.spec.ts new file mode 100644 index 00000000000..8d5bad881a2 --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-school-migration-count.loggable.spec.ts @@ -0,0 +1,26 @@ +import { TspLegacySchoolMigrationCountLoggable } from './tsp-legacy-school-migration-count.loggable'; + +describe(TspLegacySchoolMigrationCountLoggable.name, () => { + let loggable: TspLegacySchoolMigrationCountLoggable; + + beforeAll(() => { + loggable = new TspLegacySchoolMigrationCountLoggable(10); + }); + + describe('when loggable is initialized', () => { + it('should be defined', () => { + expect(loggable).toBeDefined(); + }); + }); + + describe('getLogMessage', () => { + it('should return a log message', () => { + expect(loggable.getLogMessage()).toEqual({ + message: `Found 10 legacy tsp schools to migrate`, + data: { + total: 10, + }, + }); + }); + }); +}); diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-school-migration-count.loggable.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-school-migration-count.loggable.ts new file mode 100644 index 00000000000..c04fc6b5a53 --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-school-migration-count.loggable.ts @@ -0,0 +1,16 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class TspLegacySchoolMigrationCountLoggable implements Loggable { + constructor(private readonly total: number) {} + + getLogMessage(): LogMessage { + const message: LogMessage = { + message: `Found ${this.total} legacy tsp schools to migrate`, + data: { + total: this.total, + }, + }; + + return message; + } +} diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-school-migration-success.loggable.spec.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-school-migration-success.loggable.spec.ts new file mode 100644 index 00000000000..d8bd6bee2ac --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-school-migration-success.loggable.spec.ts @@ -0,0 +1,27 @@ +import { TspLegacySchoolMigrationSuccessLoggable } from './tsp-legacy-school-migration-success.loggable'; + +describe(TspLegacySchoolMigrationSuccessLoggable.name, () => { + let loggable: TspLegacySchoolMigrationSuccessLoggable; + + beforeAll(() => { + loggable = new TspLegacySchoolMigrationSuccessLoggable(10, 5); + }); + + describe('when loggable is initialized', () => { + it('should be defined', () => { + expect(loggable).toBeDefined(); + }); + }); + + describe('getLogMessage', () => { + it('should return a log message', () => { + expect(loggable.getLogMessage()).toEqual({ + message: `Legacy tsp data migration finished. Total schools: 10, migrated schools: 5`, + data: { + total: 10, + migrated: 5, + }, + }); + }); + }); +}); diff --git a/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-school-migration-success.loggable.ts b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-school-migration-success.loggable.ts new file mode 100644 index 00000000000..b6ac7b247e2 --- /dev/null +++ b/apps/server/src/infra/sync/tsp/loggable/tsp-legacy-school-migration-success.loggable.ts @@ -0,0 +1,17 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class TspLegacySchoolMigrationSuccessLoggable implements Loggable { + constructor(private readonly total: number, private readonly migrated: number) {} + + getLogMessage(): LogMessage { + const message: LogMessage = { + message: `Legacy tsp data migration finished. Total schools: ${this.total}, migrated schools: ${this.migrated}`, + data: { + total: this.total, + migrated: this.migrated, + }, + }; + + return message; + } +} diff --git a/apps/server/src/infra/sync/tsp/tsp-fetch.service.spec.ts b/apps/server/src/infra/sync/tsp/tsp-fetch.service.spec.ts new file mode 100644 index 00000000000..4c7c76c8ea5 --- /dev/null +++ b/apps/server/src/infra/sync/tsp/tsp-fetch.service.spec.ts @@ -0,0 +1,438 @@ +import { faker } from '@faker-js/faker'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AxiosErrorLoggable, ErrorLoggable } from '@src/core/error/loggable'; +import { Logger } from '@src/core/logger'; +import { + ExportApiInterface, + RobjExportKlasse, + RobjExportLehrer, + RobjExportLehrerMigration, + RobjExportSchueler, + RobjExportSchuelerMigration, + RobjExportSchule, + TspClientFactory, +} from '@src/infra/tsp-client'; +import { OauthConfigMissingLoggableException } from '@src/modules/oauth/loggable'; +import { systemFactory } from '@src/modules/system/testing'; +import { AxiosError, AxiosResponse } from 'axios'; +import { TspFetchService } from './tsp-fetch.service'; + +describe(TspFetchService.name, () => { + let module: TestingModule; + let sut: TspFetchService; + let tspClientFactory: DeepMocked; + let logger: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + TspFetchService, + { + provide: TspClientFactory, + useValue: createMock(), + }, + { + provide: Logger, + useValue: createMock(), + }, + ], + }).compile(); + + sut = module.get(TspFetchService); + tspClientFactory = module.get(TspClientFactory); + logger = module.get(Logger); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('when sync service is initialized', () => { + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + }); + + const setupTspClient = () => { + const clientId = faker.string.alpha(); + const clientSecret = faker.string.alpha(); + const tokenEndpoint = faker.internet.url(); + const system = systemFactory.build({ + oauthConfig: { + clientId, + clientSecret, + tokenEndpoint, + }, + }); + + const tspSchool: RobjExportSchule = { + schuleName: faker.string.alpha(), + schuleNummer: faker.string.alpha(), + }; + const schools = [tspSchool]; + const responseSchools = createMock>>({ + data: schools, + }); + + const tspTeacher: RobjExportLehrer = { + schuleNummer: faker.string.alpha(), + lehrerVorname: faker.string.alpha(), + lehrerNachname: faker.string.alpha(), + lehrerUid: faker.string.alpha(), + }; + const teachers = [tspTeacher]; + const responseTeachers = createMock>>({ + data: teachers, + }); + + const tspStudent: RobjExportSchueler = { + schuleNummer: faker.string.alpha(), + schuelerVorname: faker.string.alpha(), + schuelerNachname: faker.string.alpha(), + schuelerUid: faker.string.alpha(), + }; + const students = [tspStudent]; + const responseStudents = createMock>>({ + data: students, + }); + + const tspClass: RobjExportKlasse = { + schuleNummer: faker.string.alpha(), + klasseId: faker.string.alpha(), + klasseName: faker.string.alpha(), + lehrerUid: faker.string.alpha(), + }; + const classes = [tspClass]; + const responseClasses = createMock>>({ + data: classes, + }); + + const tspTeacherMigration: RobjExportLehrerMigration = { + lehrerUidAlt: faker.string.alpha(), + lehrerUidNeu: faker.string.alpha(), + }; + const teacherMigrations = [tspTeacherMigration]; + const responseTeacherMigrations = createMock>>({ + data: teacherMigrations, + }); + + const tspStudentMigration: RobjExportSchuelerMigration = { + schuelerUidAlt: faker.string.alpha(), + schuelerUidNeu: faker.string.alpha(), + }; + const studentMigrations = [tspStudentMigration]; + const responseStudentMigrations = createMock>>({ + data: studentMigrations, + }); + + const exportApiMock = createMock(); + exportApiMock.exportSchuleList.mockResolvedValueOnce(responseSchools); + exportApiMock.exportLehrerList.mockResolvedValueOnce(responseTeachers); + exportApiMock.exportSchuelerList.mockResolvedValueOnce(responseStudents); + exportApiMock.exportKlasseList.mockResolvedValueOnce(responseClasses); + exportApiMock.exportLehrerListMigration.mockResolvedValueOnce(responseTeacherMigrations); + exportApiMock.exportSchuelerListMigration.mockResolvedValueOnce(responseStudentMigrations); + + tspClientFactory.createExportClient.mockReturnValueOnce(exportApiMock); + + return { + clientId, + clientSecret, + tokenEndpoint, + system, + exportApiMock, + schools, + teachers, + students, + classes, + teacherMigrations, + studentMigrations, + }; + }; + + describe('fetchTspSchools', () => { + describe('when tsp schools are fetched', () => { + it('should use the oauthConfig to create the client', async () => { + const { clientId, clientSecret, tokenEndpoint, system } = setupTspClient(); + + await sut.fetchTspSchools(system, 1); + + expect(tspClientFactory.createExportClient).toHaveBeenCalledWith({ + clientId, + clientSecret, + tokenEndpoint, + }); + }); + + it('should call exportSchuleList', async () => { + const { system, exportApiMock } = setupTspClient(); + + await sut.fetchTspSchools(system, 1); + + expect(exportApiMock.exportSchuleList).toHaveBeenCalledTimes(1); + }); + + it('should return an array of schools', async () => { + const { system } = setupTspClient(); + + const schools = await sut.fetchTspSchools(system, 1); + + expect(schools).toBeDefined(); + expect(schools).toBeInstanceOf(Array); + }); + }); + }); + + describe('fetchTspTeachers', () => { + describe('when tsp teachers are fetched', () => { + it('should use the oauthConfig to create the client', async () => { + const { clientId, clientSecret, tokenEndpoint, system } = setupTspClient(); + + await sut.fetchTspTeachers(system, 1); + + expect(tspClientFactory.createExportClient).toHaveBeenCalledWith({ + clientId, + clientSecret, + tokenEndpoint, + }); + }); + + it('should call exportLehrerList', async () => { + const { system, exportApiMock } = setupTspClient(); + + await sut.fetchTspTeachers(system, 1); + + expect(exportApiMock.exportLehrerList).toHaveBeenCalledTimes(1); + }); + + it('should return an array of teachers', async () => { + const { system } = setupTspClient(); + + const teachers = await sut.fetchTspTeachers(system, 1); + + expect(teachers).toBeDefined(); + expect(teachers).toBeInstanceOf(Array); + }); + }); + }); + + describe('fetchTspStudents', () => { + describe('when tsp students are fetched', () => { + it('should use the oauthConfig to create the client', async () => { + const { clientId, clientSecret, tokenEndpoint, system } = setupTspClient(); + + await sut.fetchTspStudents(system, 1); + + expect(tspClientFactory.createExportClient).toHaveBeenCalledWith({ + clientId, + clientSecret, + tokenEndpoint, + }); + }); + + it('should call exportSchuelerList', async () => { + const { system, exportApiMock } = setupTspClient(); + + await sut.fetchTspStudents(system, 1); + + expect(exportApiMock.exportSchuelerList).toHaveBeenCalledTimes(1); + }); + + it('should return an array of students', async () => { + const { system } = setupTspClient(); + + const students = await sut.fetchTspStudents(system, 1); + + expect(students).toBeDefined(); + expect(students).toBeInstanceOf(Array); + }); + }); + }); + + describe('fetchTspClasses', () => { + describe('when tsp classes are fetched', () => { + it('should use the oauthConfig to create the client', async () => { + const { clientId, clientSecret, tokenEndpoint, system } = setupTspClient(); + + await sut.fetchTspClasses(system, 1); + + expect(tspClientFactory.createExportClient).toHaveBeenCalledWith({ + clientId, + clientSecret, + tokenEndpoint, + }); + }); + + it('should call exportKlasseList', async () => { + const { system, exportApiMock } = setupTspClient(); + + await sut.fetchTspClasses(system, 1); + + expect(exportApiMock.exportKlasseList).toHaveBeenCalledTimes(1); + }); + + it('should return an array of classes', async () => { + const { system } = setupTspClient(); + + const classes = await sut.fetchTspClasses(system, 1); + + expect(classes).toBeDefined(); + expect(classes).toBeInstanceOf(Array); + }); + }); + }); + + describe('fetchTspTeacherMigrations', () => { + describe('when tsp teacher migrations are fetched', () => { + it('should create the client', async () => { + const { clientId, clientSecret, tokenEndpoint, system } = setupTspClient(); + + await sut.fetchTspTeacherMigrations(system); + + expect(tspClientFactory.createExportClient).toHaveBeenCalledWith({ + clientId, + clientSecret, + tokenEndpoint, + }); + }); + + it('should call exportLehrerListMigration', async () => { + const { system, exportApiMock } = setupTspClient(); + + await sut.fetchTspTeacherMigrations(system); + + expect(exportApiMock.exportLehrerListMigration).toHaveBeenCalledTimes(1); + }); + + it('should return an array of teacher migrations', async () => { + const { system } = setupTspClient(); + + const result = await sut.fetchTspTeacherMigrations(system); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(Array); + }); + }); + }); + + describe('fetchTspStudentMigrations', () => { + describe('when tsp student migrations are fetched', () => { + it('should create the client', async () => { + const { clientId, clientSecret, tokenEndpoint, system } = setupTspClient(); + + await sut.fetchTspStudentMigrations(system); + + expect(tspClientFactory.createExportClient).toHaveBeenCalledWith({ + clientId, + clientSecret, + tokenEndpoint, + }); + }); + + it('should call exportSchuelerListMigration', async () => { + const { system, exportApiMock } = setupTspClient(); + + await sut.fetchTspStudentMigrations(system); + + expect(exportApiMock.exportSchuelerListMigration).toHaveBeenCalledTimes(1); + }); + + it('should return an array of student migrations', async () => { + const { system } = setupTspClient(); + + const result = await sut.fetchTspStudentMigrations(system); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(Array); + }); + }); + }); + + describe('fetchTsp', () => { + describe('when AxiosError is thrown', () => { + const setup = () => { + const system = systemFactory.build({ + oauthConfig: { + clientId: faker.string.alpha(), + clientSecret: faker.string.alpha(), + tokenEndpoint: faker.internet.url(), + }, + }); + + const exportApiMock = createMock(); + exportApiMock.exportSchuleList.mockImplementation(() => { + throw new AxiosError(); + }); + + tspClientFactory.createExportClient.mockReturnValueOnce(exportApiMock); + + return { + system, + exportApiMock, + }; + }; + + it('should log a AxiosErrorLoggable as warning', async () => { + const { system } = setup(); + + await sut.fetchTspSchools(system, 1); + + expect(logger.warning).toHaveBeenCalledWith(new AxiosErrorLoggable(new AxiosError(), 'TSP_FETCH_ERROR')); + }); + }); + + describe('when generic Error is thrown', () => { + const setup = () => { + const system = systemFactory.build({ + oauthConfig: { + clientId: faker.string.alpha(), + clientSecret: faker.string.alpha(), + tokenEndpoint: faker.internet.url(), + }, + }); + + const exportApiMock = createMock(); + exportApiMock.exportSchuleList.mockImplementation(() => { + throw new Error(); + }); + + tspClientFactory.createExportClient.mockReturnValueOnce(exportApiMock); + + return { + system, + exportApiMock, + }; + }; + + it('should log a ErrorLoggable as warning', async () => { + const { system } = setup(); + + await sut.fetchTspSchools(system, 1); + + expect(logger.warning).toHaveBeenCalledWith(new ErrorLoggable(new Error())); + }); + }); + }); + + describe('createClient', () => { + describe('when oauthConfig is missing', () => { + const setup = () => { + const system = systemFactory.build({ oauthConfig: undefined }); + + return { system }; + }; + + it('should throw an OauthConfigMissingLoggableException', async () => { + const { system } = setup(); + + await expect(async () => sut.fetchTspSchools(system, 1)).rejects.toThrow(OauthConfigMissingLoggableException); + }); + }); + }); +}); diff --git a/apps/server/src/infra/sync/tsp/tsp-fetch.service.ts b/apps/server/src/infra/sync/tsp/tsp-fetch.service.ts new file mode 100644 index 00000000000..9285fbfd9a8 --- /dev/null +++ b/apps/server/src/infra/sync/tsp/tsp-fetch.service.ts @@ -0,0 +1,94 @@ +import { Injectable } from '@nestjs/common'; +import { AxiosErrorLoggable, ErrorLoggable } from '@src/core/error/loggable'; +import { Logger } from '@src/core/logger'; +import { ExportApiInterface, TspClientFactory } from '@src/infra/tsp-client'; +import { OauthConfigMissingLoggableException } from '@src/modules/oauth/loggable'; +import { System } from '@src/modules/system'; +import { AxiosError, AxiosResponse } from 'axios'; +import moment from 'moment'; + +@Injectable() +export class TspFetchService { + constructor(private readonly tspClientFactory: TspClientFactory, private readonly logger: Logger) { + this.logger.setContext(TspFetchService.name); + } + + public async fetchTspSchools(system: System, daysToFetch: number) { + const lastChangeDate = this.formatChangeDate(daysToFetch); + const schools = await this.fetchTsp(system, (client) => client.exportSchuleList(lastChangeDate), []); + + return schools; + } + + public async fetchTspTeachers(system: System, daysToFetch: number) { + const lastChangeDate = this.formatChangeDate(daysToFetch); + const teachers = await this.fetchTsp(system, (client) => client.exportLehrerList(lastChangeDate), []); + + return teachers; + } + + public async fetchTspStudents(system: System, daysToFetch: number) { + const lastChangeDate = this.formatChangeDate(daysToFetch); + const students = await this.fetchTsp(system, (client) => client.exportSchuelerList(lastChangeDate), []); + + return students; + } + + public async fetchTspClasses(system: System, daysToFetch: number) { + const lastChangeDate = this.formatChangeDate(daysToFetch); + const classes = await this.fetchTsp(system, (client) => client.exportKlasseList(lastChangeDate), []); + + return classes; + } + + public async fetchTspTeacherMigrations(system: System) { + const migrations = this.fetchTsp(system, (client) => client.exportLehrerListMigration(), []); + + return migrations; + } + + public async fetchTspStudentMigrations(system: System) { + const migrations = this.fetchTsp(system, (client) => client.exportSchuelerListMigration(), []); + + return migrations; + } + + private async fetchTsp( + system: System, + fetch: (client: ExportApiInterface) => Promise>, + defaultValue: T + ): Promise { + const client = this.createClient(system); + try { + const response = await fetch(client); + const { data } = response; + + return data; + } catch (e) { + if (e instanceof AxiosError) { + this.logger.warning(new AxiosErrorLoggable(e, 'TSP_FETCH_ERROR')); + } else { + this.logger.warning(new ErrorLoggable(e)); + } + } + return defaultValue; + } + + private formatChangeDate(daysToFetch: number): string { + return moment(new Date()).subtract(daysToFetch, 'days').subtract(1, 'hours').format('YYYY-MM-DD HH:mm:ss.SSS'); + } + + private createClient(system: System) { + if (!system.oauthConfig) { + throw new OauthConfigMissingLoggableException(system.id); + } + + const client = this.tspClientFactory.createExportClient({ + clientId: system.oauthConfig.clientId, + clientSecret: system.oauthConfig.clientSecret, + tokenEndpoint: system.oauthConfig.tokenEndpoint, + }); + + return client; + } +} diff --git a/apps/server/src/infra/sync/tsp/tsp-legacy-migration.service.integration.spec.ts b/apps/server/src/infra/sync/tsp/tsp-legacy-migration.service.integration.spec.ts new file mode 100644 index 00000000000..ee52feb75cf --- /dev/null +++ b/apps/server/src/infra/sync/tsp/tsp-legacy-migration.service.integration.spec.ts @@ -0,0 +1,106 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { SchoolEntity } from '@shared/domain/entity'; +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { SchoolFeature } from '@shared/domain/types'; +import { cleanupCollections, schoolEntityFactory, systemEntityFactory } from '@shared/testing'; +import { Logger } from '@src/core/logger'; +import { MongoMemoryDatabaseModule } from '@src/infra/database'; +import { SystemType } from '@src/modules/system'; +import { TspLegacyMigrationSystemMissingLoggable } from './loggable/tsp-legacy-migration-system-missing.loggable'; +import { TspLegacyMigrationService } from './tsp-legacy-migration.service'; + +describe('account repo', () => { + let module: TestingModule; + let em: EntityManager; + let sut: TspLegacyMigrationService; + let logger: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot()], + providers: [ + TspLegacyMigrationService, + { + provide: Logger, + useValue: createMock(), + }, + ], + }).compile(); + sut = module.get(TspLegacyMigrationService); + em = module.get(EntityManager); + logger = module.get(Logger); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + jest.resetAllMocks(); + jest.clearAllMocks(); + jest.restoreAllMocks(); + await cleanupCollections(em); + }); + + describe('migrateLegacyData', () => { + describe('when legacy system is not found', () => { + it('should log TspLegacyMigrationSystemMissingLoggable', async () => { + await sut.migrateLegacyData(''); + + expect(logger.info).toHaveBeenCalledWith(new TspLegacyMigrationSystemMissingLoggable()); + }); + }); + + describe('when migrating legacy data', () => { + const setup = async () => { + const legacySystem = systemEntityFactory.buildWithId({ + type: 'tsp-school', + }); + const newSystem = systemEntityFactory.buildWithId({ + type: SystemType.OAUTH, + provisioningStrategy: SystemProvisioningStrategy.TSP, + }); + + const schoolIdentifier = '123'; + const legacySchool = schoolEntityFactory.buildWithId({ + systems: [legacySystem], + features: [], + }); + + await em.persistAndFlush([legacySystem, newSystem, legacySchool]); + em.clear(); + + await em.getCollection('schools').findOneAndUpdate( + { + systems: [legacySystem._id], + }, + { + $set: { + sourceOptions: { + schoolIdentifier, + }, + source: 'tsp', + }, + } + ); + + return { legacySystem, newSystem, legacySchool, schoolId: schoolIdentifier }; + }; + + it('should update the school to the new format', async () => { + const { newSystem, legacySchool, schoolId: schoolIdentifier } = await setup(); + + await sut.migrateLegacyData(newSystem.id); + + const migratedSchool = await em.findOne(SchoolEntity.name, { + id: legacySchool.id, + }); + expect(migratedSchool?.externalId).toBe(schoolIdentifier); + expect(migratedSchool?.systems[0].id).toBe(newSystem.id); + expect(migratedSchool?.features).toContain(SchoolFeature.OAUTH_PROVISIONING_ENABLED); + }); + }); + }); +}); diff --git a/apps/server/src/infra/sync/tsp/tsp-legacy-migration.service.ts b/apps/server/src/infra/sync/tsp/tsp-legacy-migration.service.ts new file mode 100644 index 00000000000..1bff95ef2b5 --- /dev/null +++ b/apps/server/src/infra/sync/tsp/tsp-legacy-migration.service.ts @@ -0,0 +1,93 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { EntityId, SchoolFeature } from '@shared/domain/types'; +import { Logger } from '@src/core/logger'; +import { TspLegacyMigrationStartLoggable } from './loggable/tsp-legacy-migration-start.loggable'; +import { TspLegacyMigrationSystemMissingLoggable } from './loggable/tsp-legacy-migration-system-missing.loggable'; +import { TspLegacySchoolMigrationCountLoggable } from './loggable/tsp-legacy-school-migration-count.loggable'; +import { TspLegacySchoolMigrationSuccessLoggable } from './loggable/tsp-legacy-school-migration-success.loggable'; + +type LegacyTspSchoolProperties = { + sourceOptions: { + schoolIdentifier: number; + }; +}; + +const TSP_LEGACY_SYSTEM_TYPE = 'tsp-school'; +const TSP_LEGACY_SOURCE_TYPE = 'tsp'; +const SCHOOLS_COLLECTION = 'schools'; +const SYSTEMS_COLLECTION = 'systems'; + +@Injectable() +export class TspLegacyMigrationService { + constructor(private readonly em: EntityManager, private readonly logger: Logger) { + logger.setContext(TspLegacyMigrationService.name); + } + + public async migrateLegacyData(newSystemId: EntityId): Promise { + this.logger.info(new TspLegacyMigrationStartLoggable()); + + const legacySystemId = await this.findLegacySystemId(); + + if (!legacySystemId) { + this.logger.info(new TspLegacyMigrationSystemMissingLoggable()); + return; + } + + const schoolIds = await this.findIdsOfLegacyTspSchools(legacySystemId); + + this.logger.info(new TspLegacySchoolMigrationCountLoggable(schoolIds.length)); + + const promises = schoolIds.map(async (oldId): Promise => { + const legacySchoolFilter = { + systems: [legacySystemId], + source: TSP_LEGACY_SOURCE_TYPE, + sourceOptions: { + schoolIdentifier: oldId, + }, + }; + + const featureUpdateCount = await this.em.nativeUpdate(SCHOOLS_COLLECTION, legacySchoolFilter, { + $addToSet: { + features: SchoolFeature.OAUTH_PROVISIONING_ENABLED, + }, + }); + const idUpdateCount = await this.em.nativeUpdate(SCHOOLS_COLLECTION, legacySchoolFilter, { + ldapSchoolIdentifier: oldId, + systems: [new ObjectId(newSystemId)], + }); + + return featureUpdateCount === 1 && idUpdateCount === 1 ? 1 : 0; + }); + + const results = await Promise.allSettled(promises); + const successfulMigrations = results + .filter((r) => r.status === 'fulfilled') + .map((r) => r.value) + .reduce((previousValue, currentValue) => previousValue + currentValue, 0); + + this.logger.info(new TspLegacySchoolMigrationSuccessLoggable(schoolIds.length, successfulMigrations)); + } + + private async findLegacySystemId() { + const tspLegacySystem = await this.em.getCollection(SYSTEMS_COLLECTION).findOne({ + type: TSP_LEGACY_SYSTEM_TYPE, + }); + + return tspLegacySystem?._id; + } + + private async findIdsOfLegacyTspSchools(legacySystemId: ObjectId) { + const schools = await this.em + .getCollection(SCHOOLS_COLLECTION) + .find({ + systems: [legacySystemId], + source: TSP_LEGACY_SOURCE_TYPE, + }) + .toArray(); + + const schoolIds = schools.map((school) => school.sourceOptions.schoolIdentifier); + + return schoolIds; + } +} diff --git a/apps/server/src/infra/sync/tsp/tsp-sync.service.spec.ts b/apps/server/src/infra/sync/tsp/tsp-sync.service.spec.ts index 4647822ab95..b86d44430f7 100644 --- a/apps/server/src/infra/sync/tsp/tsp-sync.service.spec.ts +++ b/apps/server/src/infra/sync/tsp/tsp-sync.service.spec.ts @@ -1,36 +1,23 @@ import { faker } from '@faker-js/faker'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { - ExportApiInterface, - RobjExportKlasse, - RobjExportLehrer, - RobjExportLehrerMigration, - RobjExportSchueler, - RobjExportSchuelerMigration, - RobjExportSchule, - TspClientFactory, -} from '@infra/tsp-client'; +import { AccountService } from '@modules/account'; import { School, SchoolService } from '@modules/school'; import { SystemService, SystemType } from '@modules/system'; +import { UserService } from '@modules/user'; import { Test, TestingModule } from '@nestjs/testing'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { federalStateFactory, schoolYearFactory, userDoFactory } from '@shared/testing'; -import { AccountService } from '@src/modules/account'; import { accountDoFactory } from '@src/modules/account/testing'; import { FederalStateService, SchoolYearService } from '@src/modules/legacy-school'; -import { OauthConfigMissingLoggableException } from '@src/modules/oauth/loggable'; -import { SchoolProps } from '@src/modules/school/domain'; +import { FileStorageType, SchoolProps } from '@src/modules/school/domain'; import { FederalStateEntityMapper, SchoolYearEntityMapper } from '@src/modules/school/repo/mikro-orm/mapper'; import { schoolFactory } from '@src/modules/school/testing'; import { systemFactory } from '@src/modules/system/testing'; -import { UserService } from '@src/modules/user'; -import { AxiosResponse } from 'axios'; import { TspSyncService } from './tsp-sync.service'; describe(TspSyncService.name, () => { let module: TestingModule; let sut: TspSyncService; - let tspClientFactory: DeepMocked; let systemService: DeepMocked; let schoolService: DeepMocked; let federalStateService: DeepMocked; @@ -42,10 +29,6 @@ describe(TspSyncService.name, () => { module = await Test.createTestingModule({ providers: [ TspSyncService, - { - provide: TspClientFactory, - useValue: createMock(), - }, { provide: SystemService, useValue: createMock(), @@ -74,7 +57,6 @@ describe(TspSyncService.name, () => { }).compile(); sut = module.get(TspSyncService); - tspClientFactory = module.get(TspClientFactory); systemService = module.get(SystemService); schoolService = module.get(SchoolService); federalStateService = module.get(FederalStateService); @@ -131,235 +113,6 @@ describe(TspSyncService.name, () => { }); }); - const setupTspClient = () => { - const clientId = faker.string.alpha(); - const clientSecret = faker.string.alpha(); - const tokenEndpoint = faker.internet.url(); - const system = systemFactory.build({ - oauthConfig: { - clientId, - clientSecret, - tokenEndpoint, - }, - }); - - const tspSchool: RobjExportSchule = { - schuleName: faker.string.alpha(), - schuleNummer: faker.string.alpha(), - }; - const schools = [tspSchool]; - const responseSchools = createMock>>({ - data: schools, - }); - - const tspTeacher: RobjExportLehrer = { - schuleNummer: faker.string.alpha(), - lehrerVorname: faker.string.alpha(), - lehrerNachname: faker.string.alpha(), - lehrerUid: faker.string.alpha(), - }; - const teachers = [tspTeacher]; - const responseTeachers = createMock>>({ - data: teachers, - }); - - const tspStudent: RobjExportSchueler = { - schuleNummer: faker.string.alpha(), - schuelerVorname: faker.string.alpha(), - schuelerNachname: faker.string.alpha(), - schuelerUid: faker.string.alpha(), - }; - const students = [tspStudent]; - const responseStudents = createMock>>({ - data: students, - }); - - const tspClass: RobjExportKlasse = { - schuleNummer: faker.string.alpha(), - klasseId: faker.string.alpha(), - klasseName: faker.string.alpha(), - lehrerUid: faker.string.alpha(), - }; - const classes = [tspClass]; - const responseClasses = createMock>>({ - data: classes, - }); - - const tspTeacherMigration: RobjExportLehrerMigration = { - lehrerUidAlt: faker.string.alpha(), - lehrerUidNeu: faker.string.alpha(), - }; - const teacherMigrations = [tspTeacherMigration]; - const responseTeacherMigrations = createMock>>({ - data: teacherMigrations, - }); - - const tspStudentMigration: RobjExportSchuelerMigration = { - schuelerUidAlt: faker.string.alpha(), - schuelerUidNeu: faker.string.alpha(), - }; - const studentMigrations = [tspStudentMigration]; - const responseStudentMigrations = createMock>>({ - data: studentMigrations, - }); - - const exportApiMock = createMock(); - exportApiMock.exportSchuleList.mockResolvedValueOnce(responseSchools); - exportApiMock.exportLehrerList.mockResolvedValueOnce(responseTeachers); - exportApiMock.exportSchuelerList.mockResolvedValueOnce(responseStudents); - exportApiMock.exportKlasseList.mockResolvedValueOnce(responseClasses); - exportApiMock.exportLehrerListMigration.mockResolvedValueOnce(responseTeacherMigrations); - exportApiMock.exportSchuelerListMigration.mockResolvedValueOnce(responseStudentMigrations); - - tspClientFactory.createExportClient.mockReturnValueOnce(exportApiMock); - - return { - clientId, - clientSecret, - tokenEndpoint, - system, - exportApiMock, - schools, - teachers, - students, - classes, - teacherMigrations, - studentMigrations, - }; - }; - - describe('fetchTspSchools', () => { - describe('when tsp schools are fetched', () => { - it('should use the oauthConfig to create the client', async () => { - const { clientId, clientSecret, tokenEndpoint, system } = setupTspClient(); - - await sut.fetchTspSchools(system, 1); - - expect(tspClientFactory.createExportClient).toHaveBeenCalledWith({ - clientId, - clientSecret, - tokenEndpoint, - }); - }); - - it('should call exportSchuleList', async () => { - const { system, exportApiMock } = setupTspClient(); - - await sut.fetchTspSchools(system, 1); - - expect(exportApiMock.exportSchuleList).toHaveBeenCalledTimes(1); - }); - - it('should return an array of schools', async () => { - const { system } = setupTspClient(); - - const schools = await sut.fetchTspSchools(system, 1); - - expect(schools).toBeDefined(); - expect(schools).toBeInstanceOf(Array); - }); - }); - }); - - describe('fetchTspTeachers', () => { - describe('when tsp teachers are fetched', () => { - it('should use the oauthConfig to create the client', async () => { - const { clientId, clientSecret, tokenEndpoint, system } = setupTspClient(); - - await sut.fetchTspTeachers(system, 1); - - expect(tspClientFactory.createExportClient).toHaveBeenCalledWith({ - clientId, - clientSecret, - tokenEndpoint, - }); - }); - - it('should call exportLehrerList', async () => { - const { system, exportApiMock } = setupTspClient(); - - await sut.fetchTspTeachers(system, 1); - - expect(exportApiMock.exportLehrerList).toHaveBeenCalledTimes(1); - }); - - it('should return an array of teachers', async () => { - const { system } = setupTspClient(); - - const teachers = await sut.fetchTspTeachers(system, 1); - - expect(teachers).toBeDefined(); - expect(teachers).toBeInstanceOf(Array); - }); - }); - }); - - describe('fetchTspStudents', () => { - describe('when tsp students are fetched', () => { - it('should use the oauthConfig to create the client', async () => { - const { clientId, clientSecret, tokenEndpoint, system } = setupTspClient(); - - await sut.fetchTspStudents(system, 1); - - expect(tspClientFactory.createExportClient).toHaveBeenCalledWith({ - clientId, - clientSecret, - tokenEndpoint, - }); - }); - - it('should call exportSchuelerList', async () => { - const { system, exportApiMock } = setupTspClient(); - - await sut.fetchTspStudents(system, 1); - - expect(exportApiMock.exportSchuelerList).toHaveBeenCalledTimes(1); - }); - - it('should return an array of students', async () => { - const { system } = setupTspClient(); - - const students = await sut.fetchTspStudents(system, 1); - - expect(students).toBeDefined(); - expect(students).toBeInstanceOf(Array); - }); - }); - }); - - describe('fetchTspClasses', () => { - describe('when tsp classes are fetched', () => { - it('should use the oauthConfig to create the client', async () => { - const { clientId, clientSecret, tokenEndpoint, system } = setupTspClient(); - - await sut.fetchTspClasses(system, 1); - - expect(tspClientFactory.createExportClient).toHaveBeenCalledWith({ - clientId, - clientSecret, - tokenEndpoint, - }); - }); - - it('should call exportKlasseList', async () => { - const { system, exportApiMock } = setupTspClient(); - - await sut.fetchTspClasses(system, 1); - - expect(exportApiMock.exportKlasseList).toHaveBeenCalledTimes(1); - }); - - it('should return an array of classes', async () => { - const { system } = setupTspClient(); - - const classes = await sut.fetchTspClasses(system, 1); - - expect(classes).toBeDefined(); - expect(classes).toBeInstanceOf(Array); - }); - }); - }); - describe('findSchool', () => { describe('when school is found', () => { const setup = () => { @@ -501,6 +254,7 @@ describe(TspSyncService.name, () => { systemIds: [system.id], federalState, currentYear: schoolYear, + fileStorageType: FileStorageType.AWS_S3, }) as Partial, }); }); @@ -538,6 +292,7 @@ describe(TspSyncService.name, () => { systemIds: [system.id], federalState, currentYear: schoolYear, + fileStorageType: FileStorageType.AWS_S3, }) as Partial, }); expect(federalStateService.findFederalStateByName).not.toHaveBeenCalled(); @@ -545,72 +300,6 @@ describe(TspSyncService.name, () => { }); }); - describe('fetchTspTeacherMigrations', () => { - describe('when tsp teacher migrations are fetched', () => { - it('should create the client', async () => { - const { clientId, clientSecret, tokenEndpoint, system } = setupTspClient(); - - await sut.fetchTspTeacherMigrations(system); - - expect(tspClientFactory.createExportClient).toHaveBeenCalledWith({ - clientId, - clientSecret, - tokenEndpoint, - }); - }); - - it('should call exportLehrerListMigration', async () => { - const { system, exportApiMock } = setupTspClient(); - - await sut.fetchTspTeacherMigrations(system); - - expect(exportApiMock.exportLehrerListMigration).toHaveBeenCalledTimes(1); - }); - - it('should return an array of teacher migrations', async () => { - const { system } = setupTspClient(); - - const result = await sut.fetchTspTeacherMigrations(system); - - expect(result).toBeDefined(); - expect(result).toBeInstanceOf(Array); - }); - }); - }); - - describe('fetchTspStudentMigrations', () => { - describe('when tsp student migrations are fetched', () => { - it('should create the client', async () => { - const { clientId, clientSecret, tokenEndpoint, system } = setupTspClient(); - - await sut.fetchTspStudentMigrations(system); - - expect(tspClientFactory.createExportClient).toHaveBeenCalledWith({ - clientId, - clientSecret, - tokenEndpoint, - }); - }); - - it('should call exportSchuelerListMigration', async () => { - const { system, exportApiMock } = setupTspClient(); - - await sut.fetchTspStudentMigrations(system); - - expect(exportApiMock.exportSchuelerListMigration).toHaveBeenCalledTimes(1); - }); - - it('should return an array of student migrations', async () => { - const { system } = setupTspClient(); - - const result = await sut.fetchTspStudentMigrations(system); - - expect(result).toBeDefined(); - expect(result).toBeInstanceOf(Array); - }); - }); - }); - describe('findUserByTspUid', () => { describe('when user is found', () => { const setup = () => { @@ -650,26 +339,29 @@ describe(TspSyncService.name, () => { }); }); - describe('findAccountByTspUid', () => { + describe('findAccountByExternalId', () => { describe('when account is found', () => { const setup = () => { - const tspUid = faker.string.alpha(); + const externalId = faker.string.alpha(); + const systemId = faker.string.alpha(); + const user = userDoFactory.build(); const account = accountDoFactory.build(); - user.id = tspUid; + user.id = faker.string.alpha(); + user.externalId = externalId; account.userId = user.id; - userService.findUsers.mockResolvedValueOnce({ data: [user], total: 1 }); + userService.findByExternalId.mockResolvedValueOnce(user); accountService.findByUserId.mockResolvedValueOnce(account); - return { tspUid, account }; + return { externalId, systemId, account }; }; it('should return the account', async () => { - const { tspUid, account } = setup(); + const { externalId, systemId, account } = setup(); - const result = await sut.findAccountByTspUid(tspUid); + const result = await sut.findAccountByExternalId(externalId, systemId); expect(result).toBe(account); }); @@ -677,19 +369,19 @@ describe(TspSyncService.name, () => { describe('when account is not found', () => { const setup = () => { - const tspUid = faker.string.alpha(); - const user = userDoFactory.build(); + const externalId = faker.string.alpha(); + const systemId = faker.string.alpha(); - userService.findUsers.mockResolvedValueOnce({ data: [user], total: 0 }); + userService.findByExternalId.mockResolvedValueOnce(null); accountService.findByUserId.mockResolvedValueOnce(null); - return { tspUid }; + return { externalId, systemId }; }; it('should return null', async () => { - const { tspUid } = setup(); + const { externalId, systemId } = setup(); - const result = await sut.findAccountByTspUid(tspUid); + const result = await sut.findAccountByExternalId(externalId, systemId); expect(result).toBeNull(); }); @@ -740,20 +432,4 @@ describe(TspSyncService.name, () => { }); }); }); - - describe('createClient', () => { - describe('when oauthConfig is missing', () => { - const setup = () => { - const system = systemFactory.build({ oauthConfig: undefined }); - - return { system }; - }; - - it('should throw an OauthConfigMissingLoggableException', async () => { - const { system } = setup(); - - await expect(async () => sut.fetchTspSchools(system, 1)).rejects.toThrow(OauthConfigMissingLoggableException); - }); - }); - }); }); diff --git a/apps/server/src/infra/sync/tsp/tsp-sync.service.ts b/apps/server/src/infra/sync/tsp/tsp-sync.service.ts index 2d48792c356..c79e1165343 100644 --- a/apps/server/src/infra/sync/tsp/tsp-sync.service.ts +++ b/apps/server/src/infra/sync/tsp/tsp-sync.service.ts @@ -1,20 +1,19 @@ -import { TspClientFactory } from '@infra/tsp-client'; import { FederalStateService, SchoolYearService } from '@modules/legacy-school'; import { School, SchoolService } from '@modules/school'; import { System, SystemService, SystemType } from '@modules/system'; import { Injectable } from '@nestjs/common'; import { UserDO } from '@shared/domain/domainobject'; +import { UserSourceOptions } from '@shared/domain/domainobject/user-source-options.do'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { SchoolFeature } from '@shared/domain/types'; +import { EntityId, SchoolFeature } from '@shared/domain/types'; import { Account, AccountService } from '@src/modules/account'; import { FederalStateNames } from '@src/modules/legacy-school/types'; -import { OauthConfigMissingLoggableException } from '@src/modules/oauth/loggable'; -import { FederalState } from '@src/modules/school/domain'; +import { FederalState, FileStorageType } from '@src/modules/school/domain'; import { SchoolFactory } from '@src/modules/school/domain/factory'; +import { SchoolPermissions } from '@src/modules/school/domain/type'; import { FederalStateEntityMapper, SchoolYearEntityMapper } from '@src/modules/school/repo/mikro-orm/mapper'; import { UserService } from '@src/modules/user'; import { ObjectId } from 'bson'; -import moment from 'moment/moment'; import { TspSystemNotFoundLoggableException } from './loggable/tsp-system-not-found.loggable-exception'; @Injectable() @@ -22,7 +21,6 @@ export class TspSyncService { private federalState: FederalState | undefined; constructor( - private readonly tspClientFactory: TspClientFactory, private readonly systemService: SystemService, private readonly schoolService: SchoolService, private readonly federalStateService: FederalStateService, @@ -45,46 +43,6 @@ export class TspSyncService { return systems[0]; } - public async fetchTspSchools(system: System, daysToFetch: number) { - const client = this.createClient(system); - - const lastChangeDate = this.formatChangeDate(daysToFetch); - const schoolsResponse = await client.exportSchuleList(lastChangeDate); - const schools = schoolsResponse.data; - - return schools; - } - - public async fetchTspTeachers(system: System, daysToFetch: number) { - const client = this.createClient(system); - - const lastChangeDate = this.formatChangeDate(daysToFetch); - const teachersResponse = await client.exportLehrerList(lastChangeDate); - const teachers = teachersResponse.data; - - return teachers; - } - - public async fetchTspStudents(system: System, daysToFetch: number) { - const client = this.createClient(system); - - const lastChangeDate = this.formatChangeDate(daysToFetch); - const studentsResponse = await client.exportSchuelerList(lastChangeDate); - const students = studentsResponse.data; - - return students; - } - - public async fetchTspClasses(system: System, daysToFetch: number) { - const client = this.createClient(system); - - const lastChangeDate = this.formatChangeDate(daysToFetch); - const classesResponse = await client.exportKlasseList(lastChangeDate); - const classes = classesResponse.data; - - return classes; - } - public async findSchool(system: System, identifier: string): Promise { const schools = await this.schoolService.getSchools({ externalId: identifier, @@ -122,6 +80,12 @@ export class TspSyncService { const schoolYear = SchoolYearEntityMapper.mapToDo(schoolYearEntity); const federalState = await this.findFederalState(); + const permissions: SchoolPermissions = { + teacher: { + STUDENT_LIST: true, + }, + }; + const school = SchoolFactory.build({ externalId: identifier, name, @@ -132,6 +96,8 @@ export class TspSyncService { createdAt: new Date(), updatedAt: new Date(), id: new ObjectId().toHexString(), + fileStorageType: FileStorageType.AWS_S3, + permissions, }); const savedSchool = await this.schoolService.save(school); @@ -149,24 +115,6 @@ export class TspSyncService { return this.federalState; } - public async fetchTspTeacherMigrations(system: System) { - const client = this.createClient(system); - - const teacherMigrationsResponse = await client.exportLehrerListMigration(); - const teacherMigrations = teacherMigrationsResponse.data; - - return teacherMigrations; - } - - public async fetchTspStudentMigrations(system: System) { - const client = this.createClient(system); - - const studentMigrationsResponse = await client.exportSchuelerListMigration(); - const studentMigrations = studentMigrationsResponse.data; - - return studentMigrations; - } - public async findUserByTspUid(tspUid: string): Promise { const tspUser = await this.userService.findUsers({ tspUid }); @@ -177,8 +125,8 @@ export class TspSyncService { return tspUser.data[0]; } - public async findAccountByTspUid(tspUid: string): Promise { - const user = await this.findUserByTspUid(tspUid); + public async findAccountByExternalId(externalId: string, systemId: EntityId): Promise { + const user = await this.userService.findByExternalId(externalId, systemId); if (!user || !user.id) { return null; @@ -198,6 +146,7 @@ export class TspSyncService { user.email = email; user.externalId = externalId; user.previousExternalId = previousExternalId; + user.sourceOptions = new UserSourceOptions({ tspUid: user.externalId }); return this.userService.save(user); } @@ -208,22 +157,4 @@ export class TspSyncService { return this.accountService.save(account); } - - private formatChangeDate(daysToFetch: number): string { - return moment(new Date()).subtract(daysToFetch, 'days').subtract(1, 'hours').format('YYYY-MM-DD HH:mm:ss.SSS'); - } - - private createClient(system: System) { - if (!system.oauthConfig) { - throw new OauthConfigMissingLoggableException(system.id); - } - - const client = this.tspClientFactory.createExportClient({ - clientId: system.oauthConfig.clientId, - clientSecret: system.oauthConfig.clientSecret, - tokenEndpoint: system.oauthConfig.tokenEndpoint, - }); - - return client; - } } diff --git a/apps/server/src/infra/sync/tsp/tsp-sync.strategy.spec.ts b/apps/server/src/infra/sync/tsp/tsp-sync.strategy.spec.ts index 0b483133e90..b4d8a3b8a52 100644 --- a/apps/server/src/infra/sync/tsp/tsp-sync.strategy.spec.ts +++ b/apps/server/src/infra/sync/tsp/tsp-sync.strategy.spec.ts @@ -1,16 +1,29 @@ import { faker } from '@faker-js/faker'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { RobjExportLehrerMigration, RobjExportSchuelerMigration, RobjExportSchule } from '@infra/tsp-client'; +import { + RobjExportKlasse, + RobjExportLehrer, + RobjExportLehrerMigration, + RobjExportSchueler, + RobjExportSchuelerMigration, + RobjExportSchule, +} from '@infra/tsp-client'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; +import { UserDO } from '@shared/domain/domainobject'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { userDoFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; +import { Account } from '@src/modules/account'; import { accountDoFactory } from '@src/modules/account/testing'; import { ExternalUserDto, OauthDataDto, ProvisioningService, ProvisioningSystemDto } from '@src/modules/provisioning'; +import { School } from '@src/modules/school'; import { schoolFactory } from '@src/modules/school/testing'; +import { System } from '@src/modules/system'; import { systemFactory } from '@src/modules/system/testing'; import { SyncStrategyTarget } from '../sync-strategy.types'; +import { TspLegacyMigrationService } from './tsp-legacy-migration.service'; +import { TspFetchService } from './tsp-fetch.service'; import { TspOauthDataMapper } from './tsp-oauth-data.mapper'; import { TspSyncConfig } from './tsp-sync.config'; import { TspSyncService } from './tsp-sync.service'; @@ -20,8 +33,10 @@ describe(TspSyncStrategy.name, () => { let module: TestingModule; let sut: TspSyncStrategy; let tspSyncService: DeepMocked; + let tspFetchService: DeepMocked; let provisioningService: DeepMocked; let tspOauthDataMapper: DeepMocked; + let tspLegacyMigrationService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -31,6 +46,10 @@ describe(TspSyncStrategy.name, () => { provide: TspSyncService, useValue: createMock(), }, + { + provide: TspFetchService, + useValue: createMock(), + }, { provide: Logger, useValue: createMock(), @@ -66,13 +85,19 @@ describe(TspSyncStrategy.name, () => { provide: TspOauthDataMapper, useValue: createMock(), }, + { + provide: TspLegacyMigrationService, + useValue: createMock(), + }, ], }).compile(); sut = module.get(TspSyncStrategy); tspSyncService = module.get(TspSyncService); + tspFetchService = module.get(TspFetchService); provisioningService = module.get(ProvisioningService); tspOauthDataMapper = module.get(TspOauthDataMapper); + tspLegacyMigrationService = module.get(TspLegacyMigrationService); }); afterEach(() => { @@ -98,15 +123,47 @@ describe(TspSyncStrategy.name, () => { }); }); + const setupMockServices = (params: { + fetchedSchools?: RobjExportSchule[]; + fetchedClasses?: RobjExportKlasse[]; + fetchedTeachers?: RobjExportLehrer[]; + fetchedStudents?: RobjExportSchueler[]; + fetchedTeacherMigrations?: RobjExportLehrerMigration[]; + fetchedStudentMigrations?: RobjExportSchuelerMigration[]; + foundSchool?: School; + foundSystemSchools?: School[]; + foundTspUidUser?: UserDO | null; + foundTspUidAccount?: Account | null; + mappedOauthDto?: OauthDataDto[]; + foundSystem?: System; + updatedAccount?: Account; + updatedUser?: UserDO; + }) => { + tspFetchService.fetchTspSchools.mockResolvedValueOnce(params.fetchedSchools ?? []); + tspFetchService.fetchTspClasses.mockResolvedValueOnce(params.fetchedClasses ?? []); + tspFetchService.fetchTspStudents.mockResolvedValueOnce(params.fetchedStudents ?? []); + tspFetchService.fetchTspTeachers.mockResolvedValueOnce(params.fetchedTeachers ?? []); + tspFetchService.fetchTspTeacherMigrations.mockResolvedValueOnce(params.fetchedTeacherMigrations ?? []); + tspFetchService.fetchTspStudentMigrations.mockResolvedValueOnce(params.fetchedStudentMigrations ?? []); + + tspSyncService.findSchool.mockResolvedValue(params.foundSchool ?? undefined); + tspSyncService.findSchoolsForSystem.mockResolvedValueOnce(params.foundSystemSchools ?? []); + tspSyncService.findUserByTspUid.mockResolvedValueOnce( + params.foundTspUidUser !== undefined ? params.foundTspUidUser : userDoFactory.build() + ); + tspSyncService.updateUser.mockResolvedValueOnce(params.updatedUser ?? userDoFactory.build()); + tspSyncService.findAccountByExternalId.mockResolvedValueOnce( + params.foundTspUidAccount !== undefined ? params.foundTspUidAccount : accountDoFactory.build() + ); + tspSyncService.updateAccount.mockResolvedValueOnce(params.updatedAccount ?? accountDoFactory.build()); + tspSyncService.findTspSystemOrFail.mockResolvedValueOnce(params.foundSystem ?? systemFactory.build()); + + tspOauthDataMapper.mapTspDataToOauthData.mockReturnValueOnce(params.mappedOauthDto ?? []); + }; + describe('sync', () => { describe('when sync is called', () => { const setup = () => { - tspSyncService.fetchTspSchools.mockResolvedValueOnce([]); - tspSyncService.fetchTspClasses.mockResolvedValueOnce([]); - tspSyncService.fetchTspStudents.mockResolvedValueOnce([]); - tspSyncService.fetchTspTeachers.mockResolvedValueOnce([]); - tspSyncService.findSchoolsForSystem.mockResolvedValueOnce([]); - const oauthDataDto = new OauthDataDto({ system: new ProvisioningSystemDto({ systemId: faker.string.alpha(), @@ -116,9 +173,6 @@ describe(TspSyncStrategy.name, () => { externalId: faker.string.alpha(), }), }); - - tspOauthDataMapper.mapTspDataToOauthData.mockReturnValueOnce([oauthDataDto]); - const tspTeacher: RobjExportLehrerMigration = { lehrerUidAlt: faker.string.alpha(), lehrerUidNeu: faker.string.alpha(), @@ -129,14 +183,11 @@ describe(TspSyncStrategy.name, () => { schuelerUidNeu: faker.string.alpha(), }; - tspSyncService.fetchTspTeacherMigrations.mockResolvedValueOnce([tspTeacher]); - tspSyncService.fetchTspStudentMigrations.mockResolvedValueOnce([tspStudent]); - tspSyncService.findUserByTspUid.mockResolvedValueOnce(userDoFactory.build()); - tspSyncService.updateUser.mockResolvedValueOnce(userDoFactory.build()); - tspSyncService.findAccountByTspUid.mockResolvedValueOnce(accountDoFactory.build()); - tspSyncService.updateAccount.mockResolvedValueOnce(accountDoFactory.build()); - - tspSyncService.findTspSystemOrFail.mockResolvedValueOnce(systemFactory.build()); + setupMockServices({ + fetchedStudentMigrations: [tspStudent], + fetchedTeacherMigrations: [tspTeacher], + mappedOauthDto: [oauthDataDto], + }); return { oauthDataDto }; }; @@ -147,12 +198,20 @@ describe(TspSyncStrategy.name, () => { expect(tspSyncService.findTspSystemOrFail).toHaveBeenCalled(); }); + it('should migrate the legacy data', async () => { + setup(); + + await sut.sync(); + + expect(tspLegacyMigrationService.migrateLegacyData).toHaveBeenCalled(); + }); + it('should fetch the schools', async () => { setup(); await sut.sync(); - expect(tspSyncService.fetchTspSchools).toHaveBeenCalled(); + expect(tspFetchService.fetchTspSchools).toHaveBeenCalled(); }); it('should fetch the data', async () => { @@ -160,9 +219,9 @@ describe(TspSyncStrategy.name, () => { await sut.sync(); - expect(tspSyncService.fetchTspTeachers).toHaveBeenCalled(); - expect(tspSyncService.fetchTspStudents).toHaveBeenCalled(); - expect(tspSyncService.fetchTspClasses).toHaveBeenCalled(); + expect(tspFetchService.fetchTspTeachers).toHaveBeenCalled(); + expect(tspFetchService.fetchTspStudents).toHaveBeenCalled(); + expect(tspFetchService.fetchTspClasses).toHaveBeenCalled(); }); it('should load all schools', async () => { @@ -195,7 +254,7 @@ describe(TspSyncStrategy.name, () => { await sut.sync(); - expect(tspSyncService.fetchTspTeacherMigrations).toHaveBeenCalled(); + expect(tspFetchService.fetchTspTeacherMigrations).toHaveBeenCalled(); }); it('should fetch student migrations', async () => { @@ -203,7 +262,7 @@ describe(TspSyncStrategy.name, () => { await sut.sync(); - expect(tspSyncService.fetchTspStudentMigrations).toHaveBeenCalled(); + expect(tspFetchService.fetchTspStudentMigrations).toHaveBeenCalled(); }); it('find user by tsp Uid', async () => { @@ -227,7 +286,7 @@ describe(TspSyncStrategy.name, () => { await sut.sync(); - expect(tspSyncService.findAccountByTspUid).toHaveBeenCalled(); + expect(tspSyncService.findAccountByExternalId).toHaveBeenCalled(); }); it('should update account', async () => { @@ -247,17 +306,10 @@ describe(TspSyncStrategy.name, () => { schuleName: faker.string.alpha(), }; const tspSchools = [tspSchool]; - tspSyncService.fetchTspSchools.mockResolvedValueOnce(tspSchools); - tspSyncService.findSchool.mockResolvedValueOnce(undefined); - - tspSyncService.fetchTspClasses.mockResolvedValueOnce([]); - tspSyncService.fetchTspStudents.mockResolvedValueOnce([]); - tspSyncService.fetchTspTeachers.mockResolvedValueOnce([]); - tspSyncService.findSchoolsForSystem.mockResolvedValueOnce([]); - tspSyncService.fetchTspTeacherMigrations.mockResolvedValueOnce([]); - tspSyncService.fetchTspStudentMigrations.mockResolvedValueOnce([]); - tspOauthDataMapper.mapTspDataToOauthData.mockReturnValueOnce([]); + setupMockServices({ + fetchedSchools: tspSchools, + }); }; it('should create the school', async () => { @@ -276,18 +328,12 @@ describe(TspSyncStrategy.name, () => { schuleName: faker.string.alpha(), }; const tspSchools = [tspSchool]; - tspSyncService.fetchTspSchools.mockResolvedValueOnce(tspSchools); - const school = schoolFactory.build(); - tspSyncService.findSchool.mockResolvedValueOnce(school); - - tspSyncService.fetchTspClasses.mockResolvedValueOnce([]); - tspSyncService.fetchTspStudents.mockResolvedValueOnce([]); - tspSyncService.fetchTspTeachers.mockResolvedValueOnce([]); - tspSyncService.findSchoolsForSystem.mockResolvedValueOnce([]); - tspSyncService.fetchTspTeacherMigrations.mockResolvedValueOnce([]); - tspSyncService.fetchTspStudentMigrations.mockResolvedValueOnce([]); - tspOauthDataMapper.mapTspDataToOauthData.mockReturnValueOnce([]); + + setupMockServices({ + fetchedSchools: tspSchools, + foundSchool: school, + }); }; it('should update the school', async () => { @@ -306,15 +352,10 @@ describe(TspSyncStrategy.name, () => { schuleName: faker.string.alpha(), }; const tspSchools = [tspSchool]; - tspSyncService.fetchTspSchools.mockResolvedValueOnce(tspSchools); - - tspSyncService.fetchTspClasses.mockResolvedValueOnce([]); - tspSyncService.fetchTspStudents.mockResolvedValueOnce([]); - tspSyncService.fetchTspTeachers.mockResolvedValueOnce([]); - tspSyncService.findSchoolsForSystem.mockResolvedValueOnce([]); - tspSyncService.fetchTspTeacherMigrations.mockResolvedValueOnce([]); - tspSyncService.fetchTspStudentMigrations.mockResolvedValueOnce([]); - tspOauthDataMapper.mapTspDataToOauthData.mockReturnValueOnce([]); + + setupMockServices({ + fetchedSchools: tspSchools, + }); }; it('should skip the school', async () => { @@ -334,22 +375,15 @@ describe(TspSyncStrategy.name, () => { lehrerUidAlt: undefined, lehrerUidNeu: faker.string.alpha(), }; - - tspSyncService.fetchTspTeacherMigrations.mockResolvedValueOnce([tspTeacher]); - const tspStudent: RobjExportSchuelerMigration = { schuelerUidAlt: faker.string.alpha(), schuelerUidNeu: undefined, }; - tspSyncService.fetchTspStudentMigrations.mockResolvedValueOnce([tspStudent]); - - tspSyncService.fetchTspSchools.mockResolvedValueOnce([]); - tspSyncService.fetchTspClasses.mockResolvedValueOnce([]); - tspSyncService.fetchTspStudents.mockResolvedValueOnce([]); - tspSyncService.fetchTspTeachers.mockResolvedValueOnce([]); - tspSyncService.findTspSystemOrFail.mockResolvedValueOnce(systemFactory.build()); - tspOauthDataMapper.mapTspDataToOauthData.mockReturnValueOnce([]); + setupMockServices({ + fetchedStudentMigrations: [tspStudent], + fetchedTeacherMigrations: [tspTeacher], + }); }; it('should return false and not call findUserByTspUid', async () => { @@ -368,16 +402,10 @@ describe(TspSyncStrategy.name, () => { lehrerUidNeu: faker.string.alpha(), }; - tspSyncService.fetchTspTeacherMigrations.mockResolvedValueOnce([tspTeacher]); - tspSyncService.findUserByTspUid.mockResolvedValueOnce(null); - - tspSyncService.fetchTspSchools.mockResolvedValueOnce([]); - tspSyncService.fetchTspClasses.mockResolvedValueOnce([]); - tspSyncService.fetchTspStudents.mockResolvedValueOnce([]); - tspSyncService.fetchTspTeachers.mockResolvedValueOnce([]); - tspSyncService.fetchTspStudentMigrations.mockResolvedValueOnce([]); - tspSyncService.findTspSystemOrFail.mockResolvedValueOnce(systemFactory.build()); - tspOauthDataMapper.mapTspDataToOauthData.mockReturnValueOnce([]); + setupMockServices({ + fetchedTeacherMigrations: [tspTeacher], + foundTspUidUser: null, + }); return { tspTeacher }; }; @@ -398,19 +426,10 @@ describe(TspSyncStrategy.name, () => { lehrerUidNeu: faker.string.alpha(), }; - tspSyncService.fetchTspTeacherMigrations.mockResolvedValueOnce([tspTeacher]); - tspSyncService.findAccountByTspUid.mockResolvedValueOnce(null); - - tspSyncService.fetchTspSchools.mockResolvedValueOnce([]); - tspSyncService.fetchTspClasses.mockResolvedValueOnce([]); - tspSyncService.fetchTspStudents.mockResolvedValueOnce([]); - tspSyncService.fetchTspTeachers.mockResolvedValueOnce([]); - - tspSyncService.fetchTspStudentMigrations.mockResolvedValueOnce([]); - tspSyncService.findUserByTspUid.mockResolvedValueOnce(userDoFactory.build()); - tspSyncService.updateUser.mockResolvedValueOnce(userDoFactory.build()); - tspSyncService.findTspSystemOrFail.mockResolvedValueOnce(systemFactory.build()); - tspOauthDataMapper.mapTspDataToOauthData.mockReturnValueOnce([]); + setupMockServices({ + fetchedTeacherMigrations: [tspTeacher], + foundTspUidAccount: null, + }); }; it('should throw and not call updateAccount', async () => { diff --git a/apps/server/src/infra/sync/tsp/tsp-sync.strategy.ts b/apps/server/src/infra/sync/tsp/tsp-sync.strategy.ts index e8b99104cc0..07469f5abd6 100644 --- a/apps/server/src/infra/sync/tsp/tsp-sync.strategy.ts +++ b/apps/server/src/infra/sync/tsp/tsp-sync.strategy.ts @@ -24,6 +24,8 @@ import { TspUsersMigratedLoggable } from './loggable/tsp-users-migrated.loggable import { TspOauthDataMapper } from './tsp-oauth-data.mapper'; import { TspSyncConfig } from './tsp-sync.config'; import { TspSyncService } from './tsp-sync.service'; +import { TspLegacyMigrationService } from './tsp-legacy-migration.service'; +import { TspFetchService } from './tsp-fetch.service'; @Injectable() export class TspSyncStrategy extends SyncStrategy { @@ -42,7 +44,9 @@ export class TspSyncStrategy extends SyncStrategy { constructor( private readonly logger: Logger, private readonly tspSyncService: TspSyncService, + private readonly tspFetchService: TspFetchService, private readonly tspOauthDataMapper: TspOauthDataMapper, + private readonly tspLegacyMigrationService: TspLegacyMigrationService, configService: ConfigService, private readonly provisioningService: ProvisioningService ) { @@ -66,6 +70,8 @@ export class TspSyncStrategy extends SyncStrategy { public async sync(): Promise { const system = await this.tspSyncService.findTspSystemOrFail(); + await this.tspLegacyMigrationService.migrateLegacyData(system.id); + await this.syncSchools(system); const schools = await this.tspSyncService.findSchoolsForSystem(system); @@ -81,7 +87,7 @@ export class TspSyncStrategy extends SyncStrategy { } private async syncSchools(system: System): Promise { - const tspSchools = await this.tspSyncService.fetchTspSchools(system, this.schoolDaysToFetch); + const tspSchools = await this.tspFetchService.fetchTspSchools(system, this.schoolDaysToFetch); this.logger.info(new TspSchoolsFetchedLoggable(tspSchools.length, this.schoolDaysToFetch)); const schoolPromises = tspSchools.map((tspSchool) => @@ -119,9 +125,9 @@ export class TspSyncStrategy extends SyncStrategy { } private async syncData(system: System, schools: School[]): Promise { - const tspTeachers = await this.tspSyncService.fetchTspTeachers(system, this.schoolDataDaysToFetch); - const tspStudents = await this.tspSyncService.fetchTspStudents(system, this.schoolDataDaysToFetch); - const tspClasses = await this.tspSyncService.fetchTspClasses(system, this.schoolDataDaysToFetch); + const tspTeachers = await this.tspFetchService.fetchTspTeachers(system, this.schoolDataDaysToFetch); + const tspStudents = await this.tspFetchService.fetchTspStudents(system, this.schoolDataDaysToFetch); + const tspClasses = await this.tspFetchService.fetchTspClasses(system, this.schoolDataDaysToFetch); this.logger.info( new TspDataFetchedLoggable(tspTeachers.length, tspStudents.length, tspClasses.length, this.schoolDataDaysToFetch) ); @@ -146,7 +152,7 @@ export class TspSyncStrategy extends SyncStrategy { } private async migrateTspTeachers(system: System): Promise<{ total: number }> { - const tspTeacherIds = await this.tspSyncService.fetchTspTeacherMigrations(system); + const tspTeacherIds = await this.tspFetchService.fetchTspTeacherMigrations(system); this.logger.info(new TspTeachersFetchedLoggable(tspTeacherIds.length)); const teacherMigrationPromises = tspTeacherIds.map(({ lehrerUidAlt, lehrerUidNeu }) => @@ -168,7 +174,7 @@ export class TspSyncStrategy extends SyncStrategy { } private async migrateTspStudents(system: System): Promise<{ total: number }> { - const tspStudentIds = await this.tspSyncService.fetchTspStudentMigrations(system); + const tspStudentIds = await this.tspFetchService.fetchTspStudentMigrations(system); this.logger.info(new TspStudentsFetchedLoggable(tspStudentIds.length)); const studentMigrationPromises = tspStudentIds.map(({ schuelerUidAlt, schuelerUidNeu }) => @@ -204,7 +210,7 @@ export class TspSyncStrategy extends SyncStrategy { const newEmail = newEmailAndUsername; const updatedUser = await this.tspSyncService.updateUser(user, newEmail, newUid, oldUid); - const account = await this.tspSyncService.findAccountByTspUid(oldUid); + const account = await this.tspSyncService.findAccountByExternalId(newUid, systemId); if (!account) { throw new NotFoundLoggableException(Account.name, { oldUid }); diff --git a/apps/server/src/infra/tsp-client/tsp-client-factory.spec.ts b/apps/server/src/infra/tsp-client/tsp-client-factory.spec.ts index f4922ebec0b..b8123894961 100644 --- a/apps/server/src/infra/tsp-client/tsp-client-factory.spec.ts +++ b/apps/server/src/infra/tsp-client/tsp-client-factory.spec.ts @@ -4,7 +4,9 @@ import { OauthAdapterService } from '@modules/oauth'; import { ServerConfig } from '@modules/server'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import axios from 'axios'; +import { AxiosErrorLoggable, ErrorLoggable } from '@src/core/error/loggable'; +import { Logger } from '@src/core/logger'; +import axios, { AxiosError } from 'axios'; import { DefaultEncryptionService, EncryptionService } from '../encryption'; import { TspClientFactory } from './tsp-client-factory'; @@ -14,6 +16,7 @@ describe('TspClientFactory', () => { let configServiceMock: DeepMocked>; let oauthAdapterServiceMock: DeepMocked; let encryptionService: DeepMocked; + let logger: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -42,6 +45,10 @@ describe('TspClientFactory', () => { provide: DefaultEncryptionService, useValue: createMock(), }, + { + provide: Logger, + useValue: createMock(), + }, ], }).compile(); @@ -49,6 +56,7 @@ describe('TspClientFactory', () => { configServiceMock = module.get(ConfigService); oauthAdapterServiceMock = module.get(OauthAdapterService); encryptionService = module.get(DefaultEncryptionService); + logger = module.get(Logger); }); afterAll(async () => { @@ -57,6 +65,8 @@ describe('TspClientFactory', () => { beforeEach(() => { jest.resetAllMocks(); + jest.restoreAllMocks(); + jest.clearAllMocks(); }); it('should be defined', () => { @@ -76,56 +86,123 @@ describe('TspClientFactory', () => { expect(configServiceMock.getOrThrow).toHaveBeenCalledTimes(0); }); }); + }); + + describe('getAccessToken', () => { + describe('when called successfully', () => { + const setup = () => { + const clientId = faker.string.alpha(); + const clientSecret = faker.string.alpha(); + const tokenEndpoint = faker.internet.url(); + + oauthAdapterServiceMock.sendTokenRequest.mockResolvedValue({ + accessToken: faker.string.alpha(), + idToken: faker.string.alpha(), + refreshToken: faker.string.alpha(), + }); + + Reflect.set(sut, 'cachedToken', undefined); + + return { + clientId, + clientSecret, + tokenEndpoint, + }; + }; + + it('should return access token', async () => { + const params = setup(); + + const response = await sut.getAccessToken(params); + + expect(response).toBeDefined(); + expect(configServiceMock.getOrThrow).toHaveBeenCalledTimes(0); + expect(encryptionService.decrypt).toHaveBeenCalled(); + }); + }); describe('when token is cached', () => { const setup = () => { + const clientId = faker.string.alpha(); + const clientSecret = faker.string.alpha(); + const tokenEndpoint = faker.internet.url(); const client = sut.createExportClient({ - clientId: faker.string.alpha(), - clientSecret: faker.string.alpha(), - tokenEndpoint: faker.internet.url(), + clientId, + clientSecret, + tokenEndpoint, }); - Reflect.set(sut, 'cachedToken', faker.string.alpha()); + const cached = faker.string.alpha(); + Reflect.set(sut, 'cachedToken', cached); + Reflect.set(sut, 'tokenExpiresAt', Date.now() + 60000); - return client; + return { clientId, clientSecret, tokenEndpoint, client, cached }; }; - it('should return ExportApiInterface', () => { - const result = setup(); + it('should return ExportApiInterface', async () => { + const { clientId, clientSecret, tokenEndpoint, cached } = setup(); - expect(result).toBeDefined(); + const result = await sut.getAccessToken({ clientId, clientSecret, tokenEndpoint }); + + expect(result).toBe(cached); expect(configServiceMock.getOrThrow).toHaveBeenCalledTimes(0); }); }); - }); - describe('getAccessToken', () => { - const setup = () => { - const clientId = faker.string.alpha(); - const clientSecret = faker.string.alpha(); - const tokenEndpoint = faker.internet.url(); + describe('when an AxiosError occurs', () => { + const setup = () => { + const clientId = faker.string.alpha(); + const clientSecret = faker.string.alpha(); + const tokenEndpoint = faker.internet.url(); - oauthAdapterServiceMock.sendTokenRequest.mockResolvedValue({ - accessToken: faker.string.alpha(), - idToken: faker.string.alpha(), - refreshToken: faker.string.alpha(), + oauthAdapterServiceMock.sendTokenRequest.mockImplementation(() => { + throw new AxiosError(); + }); + + Reflect.set(sut, 'cachedToken', undefined); + + return { + clientId, + clientSecret, + tokenEndpoint, + }; + }; + + it('should log an AxiosErrorLoggable as warning and reject', async () => { + const params = setup(); + + await expect(() => sut.getAccessToken(params)).rejects.toBeUndefined(); + + expect(logger.warning).toHaveBeenCalledWith(new AxiosErrorLoggable(new AxiosError(), 'TSP_OAUTH_ERROR')); }); + }); - return { - clientId, - clientSecret, - tokenEndpoint, + describe('when a generic error occurs', () => { + const setup = () => { + const clientId = faker.string.alpha(); + const clientSecret = faker.string.alpha(); + const tokenEndpoint = faker.internet.url(); + + oauthAdapterServiceMock.sendTokenRequest.mockImplementation(() => { + throw new Error(); + }); + + Reflect.set(sut, 'cachedToken', undefined); + + return { + clientId, + clientSecret, + tokenEndpoint, + }; }; - }; - it('should return access token', async () => { - const params = setup(); + it('should log an ErrorLoggable as warning and reject', async () => { + const params = setup(); - const response = await sut.getAccessToken(params); + await expect(() => sut.getAccessToken(params)).rejects.toBeUndefined(); - expect(response).toBeDefined(); - expect(configServiceMock.getOrThrow).toHaveBeenCalledTimes(0); - expect(encryptionService.decrypt).toHaveBeenCalled(); + expect(logger.warning).toHaveBeenCalledWith(new ErrorLoggable(new Error())); + }); }); }); diff --git a/apps/server/src/infra/tsp-client/tsp-client-factory.ts b/apps/server/src/infra/tsp-client/tsp-client-factory.ts index e9890403541..53d72673aed 100644 --- a/apps/server/src/infra/tsp-client/tsp-client-factory.ts +++ b/apps/server/src/infra/tsp-client/tsp-client-factory.ts @@ -1,8 +1,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { AxiosErrorLoggable, ErrorLoggable } from '@src/core/error/loggable'; +import { Logger } from '@src/core/logger'; import { OauthAdapterService } from '@src/modules/oauth'; import { OAuthGrantType } from '@src/modules/oauth/interface/oauth-grant-type.enum'; import { ClientCredentialsGrantTokenRequest } from '@src/modules/oauth/service/dto'; +import { AxiosError } from 'axios'; import * as jwt from 'jsonwebtoken'; import { DefaultEncryptionService, EncryptionService } from '../encryption'; import { Configuration, ExportApiFactory, ExportApiInterface } from './generated'; @@ -27,7 +30,8 @@ export class TspClientFactory { constructor( private readonly oauthAdapterService: OauthAdapterService, configService: ConfigService, - @Inject(DefaultEncryptionService) private readonly encryptionService: EncryptionService + @Inject(DefaultEncryptionService) private readonly encryptionService: EncryptionService, + private readonly logger: Logger ) { this.baseUrl = configService.getOrThrow('TSP_API_CLIENT_BASE_URL'); this.tokenLifetime = configService.getOrThrow('TSP_API_CLIENT_TOKEN_LIFETIME_MS'); @@ -60,13 +64,22 @@ export class TspClientFactory { grant_type: OAuthGrantType.CLIENT_CREDENTIALS_GRANT, }); - const response = await this.oauthAdapterService.sendTokenRequest(params.tokenEndpoint, payload); - - this.cachedToken = response.accessToken; - this.tokenExpiresAt = this.getExpiresAt(now, response.accessToken); - - // We need the Bearer prefix for the generated client, because OAS 2 does not support Bearer token type - return `Bearer ${this.cachedToken}`; + try { + const response = await this.oauthAdapterService.sendTokenRequest(params.tokenEndpoint, payload); + + this.cachedToken = response.accessToken; + this.tokenExpiresAt = this.getExpiresAt(now, response.accessToken); + + // We need the Bearer prefix for the generated client, because OAS 2 does not support Bearer token type + return `Bearer ${this.cachedToken}`; + } catch (e) { + if (e instanceof AxiosError) { + this.logger.warning(new AxiosErrorLoggable(e, 'TSP_OAUTH_ERROR')); + } else { + this.logger.warning(new ErrorLoggable(e)); + } + return Promise.reject(); + } } private getExpiresAt(now: number, token: string): number { diff --git a/apps/server/src/infra/tsp-client/tsp-client.module.ts b/apps/server/src/infra/tsp-client/tsp-client.module.ts index 2c874fbf7c2..857f2b83dc3 100644 --- a/apps/server/src/infra/tsp-client/tsp-client.module.ts +++ b/apps/server/src/infra/tsp-client/tsp-client.module.ts @@ -1,10 +1,11 @@ import { OauthModule } from '@modules/oauth'; import { Module } from '@nestjs/common'; +import { LoggerModule } from '@src/core/logger'; import { EncryptionModule } from '../encryption'; import { TspClientFactory } from './tsp-client-factory'; @Module({ - imports: [OauthModule, EncryptionModule], + imports: [LoggerModule, OauthModule, EncryptionModule], providers: [TspClientFactory], exports: [TspClientFactory], }) diff --git a/apps/server/src/modules/provisioning/service/tsp-provisioning.service.spec.ts b/apps/server/src/modules/provisioning/service/tsp-provisioning.service.spec.ts index ab0d4a10832..da637580400 100644 --- a/apps/server/src/modules/provisioning/service/tsp-provisioning.service.spec.ts +++ b/apps/server/src/modules/provisioning/service/tsp-provisioning.service.spec.ts @@ -339,5 +339,34 @@ describe('TspProvisioningService', () => { expect(accountServiceMock.saveWithValidation).toHaveBeenCalledTimes(1); }); }); + + describe('when user id is not set after create', () => { + const setup = () => { + const school = schoolFactory.build(); + const data = new OauthDataDto({ + system: setupExternalSystem(), + externalUser: setupExternalUser({ + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + }), + externalSchool: setupExternalSchool(), + }); + const user = userDoFactory.build({ id: undefined, roles: [] }); + + userServiceMock.findByExternalId.mockResolvedValue(null); + userServiceMock.save.mockResolvedValue(user); + schoolServiceMock.getSchools.mockResolvedValue([school]); + roleServiceMock.findByNames.mockResolvedValue([]); + + return { data, school }; + }; + + it('should throw BadDataLoggableException', async () => { + const { data, school } = setup(); + + await expect(() => sut.provisionUser(data, school)).rejects.toThrow(BadDataLoggableException); + }); + }); }); }); diff --git a/apps/server/src/modules/provisioning/service/tsp-provisioning.service.ts b/apps/server/src/modules/provisioning/service/tsp-provisioning.service.ts index 1efb774d9de..1174d732926 100644 --- a/apps/server/src/modules/provisioning/service/tsp-provisioning.service.ts +++ b/apps/server/src/modules/provisioning/service/tsp-provisioning.service.ts @@ -4,9 +4,13 @@ import { RoleService } from '@modules/role'; import { Injectable } from '@nestjs/common'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { RoleReference, UserDO } from '@shared/domain/domainobject'; +import { Consent } from '@shared/domain/domainobject/consent'; +import { ParentConsent } from '@shared/domain/domainobject/parent-consent'; +import { UserConsent } from '@shared/domain/domainobject/user-consent'; import { RoleName } from '@shared/domain/interface'; import { School, SchoolService } from '@src/modules/school'; import { UserService } from '@src/modules/user'; +import { ObjectId } from 'bson'; import { ExternalClassDto, ExternalSchoolDto, ExternalUserDto, OauthDataDto, ProvisioningSystemDto } from '../dto'; import { BadDataLoggableException } from '../loggable'; @@ -41,7 +45,10 @@ export class TspProvisioningService { } public async provisionClasses(school: School, classes: ExternalClassDto[], user: UserDO): Promise { - if (!user.id) throw new BadDataLoggableException('User ID is missing', { user }); + if (!user.id) + throw new BadDataLoggableException('User ID is missing', { + externalId: user.externalId, + }); for await (const clazz of classes) { const currentClass = await this.classService.findClassWithSchoolIdAndExternalId(school.id, clazz.externalId); @@ -76,7 +83,11 @@ export class TspProvisioningService { } public async provisionUser(data: OauthDataDto, school: School): Promise { - if (!data.externalSchool) throw new BadDataLoggableException('External school is missing', { data }); + if (!data.externalSchool) { + throw new BadDataLoggableException('External school is missing for user', { + externalId: data.externalUser.externalId, + }); + } const existingUser = await this.userService.findByExternalId(data.externalUser.externalId, data.system.systemId); const roleRefs = await this.getRoleReferencesForUser(data.externalUser); @@ -116,7 +127,9 @@ export class TspProvisioningService { schoolId: string ): Promise { if (!externalUser.firstName || !externalUser.lastName) { - throw new BadDataLoggableException('User firstname or lastname is missing', { externalUser }); + throw new BadDataLoggableException('User firstname or lastname is missing. TspUid:', { + externalId: externalUser.externalId, + }); } const newUser = new UserDO({ @@ -128,13 +141,19 @@ export class TspProvisioningService { birthday: externalUser.birthday, externalId: externalUser.externalId, }); + + this.createTspConsent(newUser); + const savedUser = await this.userService.save(newUser); return savedUser; } private async createOrUpdateAccount(systemId: string, user: UserDO): Promise { - if (!user.id) throw new BadDataLoggableException('user ID is missing', { user }); + if (!user.id) + throw new BadDataLoggableException('user ID is missing', { + externalId: user.externalId, + }); const account = await this.accountService.findByUserId(user.id); @@ -167,4 +186,30 @@ export class TspProvisioningService { return email.toLowerCase(); } + + private createTspConsent(user: UserDO) { + const userConsent = new UserConsent({ + form: 'digital', + privacyConsent: true, + termsOfUseConsent: true, + dateOfPrivacyConsent: new Date(), + dateOfTermsOfUseConsent: new Date(), + }); + + const parentConsent = new ParentConsent({ + id: new ObjectId().toString(), + form: 'digital', + privacyConsent: true, + termsOfUseConsent: true, + dateOfPrivacyConsent: new Date(), + dateOfTermsOfUseConsent: new Date(), + }); + + const consent = new Consent({ + userConsent, + parentConsent: [parentConsent], + }); + + user.consent = consent; + } } diff --git a/apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.ts b/apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.ts index ebfdfce242e..f9f8ca71c43 100644 --- a/apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.ts @@ -74,8 +74,16 @@ export class TspProvisioningStrategy extends ProvisioningStrategy { } override async apply(data: OauthDataDto): Promise { - if (!data.externalSchool) throw new BadDataLoggableException('External school is missing', { data }); - if (!data.externalClasses) throw new BadDataLoggableException('External classes are missing', { data }); + if (!data.externalSchool) { + throw new BadDataLoggableException('External school is missing for user', { + externalId: data.externalUser.externalId, + }); + } + if (!data.externalClasses) { + throw new BadDataLoggableException('External classes are missing for user', { + externalId: data.externalUser.externalId, + }); + } const school = await this.provisioningService.findSchoolOrFail(data.system, data.externalSchool); const user = await this.provisioningService.provisionUser(data, school); diff --git a/apps/server/src/shared/domain/domainobject/consent.ts b/apps/server/src/shared/domain/domainobject/consent.ts new file mode 100644 index 00000000000..5094bb9befb --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/consent.ts @@ -0,0 +1,13 @@ +import { ParentConsent } from './parent-consent'; +import { UserConsent } from './user-consent'; + +export class Consent { + userConsent: UserConsent; + + parentConsent: ParentConsent[]; + + constructor(props: Consent) { + this.userConsent = props.userConsent; + this.parentConsent = props.parentConsent; + } +} diff --git a/apps/server/src/shared/domain/domainobject/parent-consent.ts b/apps/server/src/shared/domain/domainobject/parent-consent.ts new file mode 100644 index 00000000000..0c3a294dc36 --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/parent-consent.ts @@ -0,0 +1,24 @@ +import { EntityId } from '../types'; + +export class ParentConsent { + id: EntityId; + + form: string; + + privacyConsent: boolean; + + termsOfUseConsent: boolean; + + dateOfPrivacyConsent: Date; + + dateOfTermsOfUseConsent: Date; + + constructor(props: ParentConsent) { + this.id = props.id; + this.form = props.form; + this.privacyConsent = props.privacyConsent; + this.termsOfUseConsent = props.termsOfUseConsent; + this.dateOfPrivacyConsent = props.dateOfPrivacyConsent; + this.dateOfTermsOfUseConsent = props.dateOfTermsOfUseConsent; + } +} diff --git a/apps/server/src/shared/domain/domainobject/user-consent.ts b/apps/server/src/shared/domain/domainobject/user-consent.ts new file mode 100644 index 00000000000..419e6d73327 --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/user-consent.ts @@ -0,0 +1,19 @@ +export class UserConsent { + form: string; + + privacyConsent: boolean; + + termsOfUseConsent: boolean; + + dateOfPrivacyConsent: Date; + + dateOfTermsOfUseConsent: Date; + + constructor(props: UserConsent) { + this.form = props.form; + this.privacyConsent = props.privacyConsent; + this.termsOfUseConsent = props.termsOfUseConsent; + this.dateOfPrivacyConsent = props.dateOfPrivacyConsent; + this.dateOfTermsOfUseConsent = props.dateOfTermsOfUseConsent; + } +} diff --git a/apps/server/src/shared/domain/domainobject/user-source-options.do.spec.ts b/apps/server/src/shared/domain/domainobject/user-source-options.do.spec.ts new file mode 100644 index 00000000000..da26728ab83 --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/user-source-options.do.spec.ts @@ -0,0 +1,43 @@ +import { UserSourceOptions } from './user-source-options.do'; + +describe(UserSourceOptions.name, () => { + describe('constructor', () => { + describe('When a constructor is called', () => { + const setup = () => { + const domainObject = new UserSourceOptions({ tspUid: '12345' }); + + return { domainObject }; + }; + + it('should create empty object', () => { + const domainObject = new UserSourceOptions({}); + + expect(domainObject).toEqual(expect.objectContaining({})); + }); + + it('should contain valid tspUid ', () => { + const { domainObject } = setup(); + + const userSourceOptionsDo: UserSourceOptions = new UserSourceOptions(domainObject); + + expect(userSourceOptionsDo.tspUid).toEqual(domainObject.tspUid); + }); + }); + }); + describe('getters', () => { + describe('When getters are used', () => { + it('getters should return proper value', () => { + const props = { + tspUid: '12345', + }; + + const userSourceOptionsDo = new UserSourceOptions(props); + const gettersValues = { + tspUid: userSourceOptionsDo.tspUid, + }; + + expect(gettersValues).toEqual(props); + }); + }); + }); +}); diff --git a/apps/server/src/shared/domain/domainobject/user-source-options.do.ts b/apps/server/src/shared/domain/domainobject/user-source-options.do.ts new file mode 100644 index 00000000000..49ab42d5071 --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/user-source-options.do.ts @@ -0,0 +1,15 @@ +export interface UserSourceOptionsProps { + tspUid?: string; +} + +export class UserSourceOptions { + protected props: UserSourceOptionsProps; + + constructor(props: UserSourceOptionsProps) { + this.props = props; + } + + get tspUid(): string | undefined { + return this.props.tspUid; + } +} diff --git a/apps/server/src/shared/domain/domainobject/user.do.ts b/apps/server/src/shared/domain/domainobject/user.do.ts index b98e9303210..a100fc41149 100644 --- a/apps/server/src/shared/domain/domainobject/user.do.ts +++ b/apps/server/src/shared/domain/domainobject/user.do.ts @@ -1,7 +1,9 @@ import { LanguageType } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { BaseDO } from './base.do'; +import { Consent } from './consent'; import { RoleReference } from './role-reference'; +import { UserSourceOptions } from './user-source-options.do'; export class UserDO extends BaseDO { createdAt?: Date; @@ -50,6 +52,10 @@ export class UserDO extends BaseDO { birthday?: Date; + consent?: Consent; + + sourceOptions?: UserSourceOptions; + constructor(domainObject: UserDO) { super(domainObject.id); @@ -76,5 +82,7 @@ export class UserDO extends BaseDO { this.outdatedSince = domainObject.outdatedSince; this.previousExternalId = domainObject.previousExternalId; this.birthday = domainObject.birthday; + this.consent = domainObject.consent; + this.sourceOptions = domainObject.sourceOptions; } } diff --git a/apps/server/src/shared/repo/user/user-do.repo.ts b/apps/server/src/shared/repo/user/user-do.repo.ts index 265463f4335..3251be09ff5 100644 --- a/apps/server/src/shared/repo/user/user-do.repo.ts +++ b/apps/server/src/shared/repo/user/user-do.repo.ts @@ -4,8 +4,10 @@ import { UserQuery } from '@modules/user/service/user-query.type'; import { Injectable } from '@nestjs/common'; import { EntityNotFoundError } from '@shared/common'; import { Page, RoleReference } from '@shared/domain/domainobject'; +import { UserSourceOptions } from '@shared/domain/domainobject/user-source-options.do'; import { UserDO } from '@shared/domain/domainobject/user.do'; import { Role, SchoolEntity, User } from '@shared/domain/entity'; +import { UserSourceOptionsEntity } from '@shared/domain/entity/user-source-options-entity'; import { IFindOptions, Pagination, SortOrder, SortOrderMap } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { BaseDORepo, Scope } from '@shared/repo'; @@ -151,6 +153,7 @@ export class UserDORepo extends BaseDORepo { outdatedSince: entity.outdatedSince, previousExternalId: entity.previousExternalId, birthday: entity.birthday, + sourceOptions: entity.sourceOptions ? new UserSourceOptions({ tspUid: entity.sourceOptions.tspUid }) : undefined, }); if (entity.roles.isInitialized()) { @@ -179,6 +182,9 @@ export class UserDORepo extends BaseDORepo { outdatedSince: entityDO.outdatedSince, previousExternalId: entityDO.previousExternalId, birthday: entityDO.birthday, + sourceOptions: entityDO.sourceOptions + ? new UserSourceOptionsEntity({ tspUid: entityDO.sourceOptions.tspUid }) + : undefined, }; } diff --git a/config/default.schema.json b/config/default.schema.json index d62209b5606..00afd8e6a69 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1407,12 +1407,12 @@ }, "FEATURE_CTL_TOOLS_TAB_ENABLED": { "type": "boolean", - "default": false, + "default": true, "description": "Enables new CTL-tool tab in a course" }, "FEATURE_LTI_TOOLS_TAB_ENABLED": { "type": "boolean", - "default": true, + "default": false, "description": "Enables old LTI-tool tab in a course" }, "FEATURE_SHOW_OUTDATED_USERS": { @@ -1432,7 +1432,7 @@ }, "FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED": { "type": "boolean", - "default": false, + "default": true, "description": "Enables external tools on the column board" }, "FEATURE_PREFERRED_CTL_TOOLS_ENABLED": { diff --git a/config/development.json b/config/development.json index 382b6df002f..839b8e21bfd 100644 --- a/config/development.json +++ b/config/development.json @@ -77,7 +77,6 @@ "FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED": true, "FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED": true, "FEATURE_COLUMN_BOARD_COLLABORATIVE_TEXT_EDITOR_ENABLED": true, - "FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED": true, "FEATURE_BOARD_LAYOUT_ENABLED": true, "SCHULCONNEX_CLIENT": { "API_URL": "http://localhost:8888/v1/", diff --git a/package-lock.json b/package-lock.json index 79952875444..c0ccab39c35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -224,7 +224,7 @@ "typescript": "^5.5.4" }, "engines": { - "node": "20", + "node": "22", "npm": ">=9" } }, diff --git a/package.json b/package.json index 37f044e9f3e..1159b4954ad 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "contributors": [], "bugs": {}, "engines": { - "node": "20", + "node": "22", "npm": ">=9" }, "mikro-orm": {