From 5249f271b0e637673ea33bf4a57e6168801c0b80 Mon Sep 17 00:00:00 2001 From: Julian Aggarwal <141040785+jaggarnaut@users.noreply.github.com> Date: Mon, 24 Feb 2025 14:19:44 +0100 Subject: [PATCH] =?UTF-8?q?SPSH=201719=20FE:=20Test=20Coverage=20auf=2070%?= =?UTF-8?q?=20erh=C3=B6hen=20(#426)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * increased thresholds to 70 * added coverage for view functions * Sorting providers * Fixed linting * Calling the method specifically for Email * removed unnecessary checks * added more coverage * Covering dirtiness for KlasseCreation * covering dirtiness and form submission for schule creation * cancel import test * clear autocomplete fields test * add reset password test * lock the user test * trying to cover dirtiness for person creation * fixed tests * test change klasse * more coverage * chhange klasse refactoring * delete zuordnung test * display delete kontext success and test it * more tests * review comms --------- Co-authored-by: godtierbatuhan Co-authored-by: Timo K --- ....spec.ts => KlasseSuccessTemplate.spec.ts} | 8 +- ...Template.vue => KlasseSuccessTemplate.vue} | 0 src/components/admin/personen/PersonLock.vue | 2 +- ...e.spec.ts => RolleSuccessTemplate.spec.ts} | 10 +- ...sTemplate.vue => RolleSuccessTemplate.vue} | 0 src/views/ProfileView.spec.ts | 10 +- src/views/StartView.spec.ts | 55 ++- src/views/StartView.vue | 5 +- src/views/admin/KlasseCreationView.spec.ts | 234 +++++++-- src/views/admin/KlasseCreationView.vue | 22 +- src/views/admin/KlasseDetailsView.spec.ts | 60 ++- src/views/admin/KlasseDetailsView.vue | 6 +- src/views/admin/KlassenManagementView.spec.ts | 31 +- src/views/admin/KlassenManagementView.vue | 4 +- src/views/admin/PersonCreationView.spec.ts | 337 +++++++++++-- src/views/admin/PersonCreationView.vue | 14 +- src/views/admin/PersonDetailsView.spec.ts | 458 +++++++++++++----- src/views/admin/PersonDetailsView.vue | 28 +- src/views/admin/PersonImportView.spec.ts | 84 ++++ src/views/admin/SchuleCreationView.spec.ts | 273 ++++++++--- src/views/admin/SchuleManagementView.spec.ts | 17 +- src/views/admin/rollen/RolleCreationView.vue | 4 +- .../admin/rollen/RolleDetailsView.spec.ts | 121 ++++- src/views/admin/rollen/RolleDetailsView.vue | 17 +- .../admin/rollen/RolleManagementView.spec.ts | 44 +- vite.config.ts | 24 +- 26 files changed, 1482 insertions(+), 386 deletions(-) rename src/components/admin/klassen/{SuccessTemplate.spec.ts => KlasseSuccessTemplate.spec.ts} (85%) rename src/components/admin/klassen/{SuccessTemplate.vue => KlasseSuccessTemplate.vue} (100%) rename src/components/admin/rollen/{SuccessTemplate.spec.ts => RolleSuccessTemplate.spec.ts} (86%) rename src/components/admin/rollen/{SuccessTemplate.vue => RolleSuccessTemplate.vue} (100%) diff --git a/src/components/admin/klassen/SuccessTemplate.spec.ts b/src/components/admin/klassen/KlasseSuccessTemplate.spec.ts similarity index 85% rename from src/components/admin/klassen/SuccessTemplate.spec.ts rename to src/components/admin/klassen/KlasseSuccessTemplate.spec.ts index 0e17f27a..41fe2b6a 100644 --- a/src/components/admin/klassen/SuccessTemplate.spec.ts +++ b/src/components/admin/klassen/KlasseSuccessTemplate.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; import { VueWrapper, mount } from '@vue/test-utils'; -import SuccessTemplate from './SuccessTemplate.vue'; +import KlasseSuccessTemplate from './KlasseSuccessTemplate.vue'; let wrapper: VueWrapper | null = null; @@ -11,7 +11,7 @@ beforeEach(() => { `; - wrapper = mount(SuccessTemplate, { + wrapper = mount(KlasseSuccessTemplate, { attachTo: document.getElementById('app') || '', props: { successMessage: 'Klasse updated successfully', @@ -25,13 +25,13 @@ beforeEach(() => { }, global: { components: { - SuccessTemplate, + KlasseSuccessTemplate, }, }, }); }); -describe('SuccessTemplate', () => { +describe('KlasseSuccessTemplate', () => { test('it displays the success message and data correctly', () => { expect(wrapper?.get('[data-testid="klasse-success-text"]').text()).toBe('Klasse updated successfully'); }); diff --git a/src/components/admin/klassen/SuccessTemplate.vue b/src/components/admin/klassen/KlasseSuccessTemplate.vue similarity index 100% rename from src/components/admin/klassen/SuccessTemplate.vue rename to src/components/admin/klassen/KlasseSuccessTemplate.vue diff --git a/src/components/admin/personen/PersonLock.vue b/src/components/admin/personen/PersonLock.vue index 9923ba2b..b493488a 100644 --- a/src/components/admin/personen/PersonLock.vue +++ b/src/components/admin/personen/PersonLock.vue @@ -382,7 +382,7 @@ ) && !selectedOrganisation " @click.stop="isEditMode = true" - data-testid="lock-user-button" + data-testid="edit-user-lock-button" > {{ $t('admin.person.editLock') }} diff --git a/src/components/admin/rollen/SuccessTemplate.spec.ts b/src/components/admin/rollen/RolleSuccessTemplate.spec.ts similarity index 86% rename from src/components/admin/rollen/SuccessTemplate.spec.ts rename to src/components/admin/rollen/RolleSuccessTemplate.spec.ts index ea1c0ef0..a01717b8 100644 --- a/src/components/admin/rollen/SuccessTemplate.spec.ts +++ b/src/components/admin/rollen/RolleSuccessTemplate.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; import { VueWrapper, mount } from '@vue/test-utils'; -import SuccessTemplate from './SuccessTemplate.vue'; +import RolleSuccessTemplate from './RolleSuccessTemplate.vue'; let wrapper: VueWrapper | null = null; @@ -11,7 +11,7 @@ beforeEach(() => { `; - wrapper = mount(SuccessTemplate, { + wrapper = mount(RolleSuccessTemplate, { attachTo: document.getElementById('app') || '', props: { successMessage: 'Role updated successfully', @@ -20,7 +20,7 @@ beforeEach(() => { { label: 'Role Name', value: 'Test Role', testId: 'updated-rolle-name' }, { label: 'Merkmale', value: 'Merkmal 1, Merkmal 2', testId: 'updated-rolle-merkmale' }, { label: 'Assigned Service Providers', value: 'Service Provider 1', testId: 'updated-rolle-angebote' }, - { label: 'System Rights', value: 'Systemrecht 1', testId: 'updated-rolle-systemrecht' }, + { label: 'System Rights', value: 'Systemrecht 1', testId: 'updated-rolle-systemrechte' }, ], backButtonText: 'Back to List', createAnotherRolleButtonText: 'Create Another', @@ -30,13 +30,13 @@ beforeEach(() => { }, global: { components: { - SuccessTemplate, + RolleSuccessTemplate, }, }, }); }); -describe('SuccessTemplate', () => { +describe('RolleSuccessTemplate', () => { test('it displays the success message and data correctly', () => { expect(wrapper?.get('[data-testid="rolle-success-text"]').text()).toBe('Role updated successfully'); }); diff --git a/src/components/admin/rollen/SuccessTemplate.vue b/src/components/admin/rollen/RolleSuccessTemplate.vue similarity index 100% rename from src/components/admin/rollen/SuccessTemplate.vue rename to src/components/admin/rollen/RolleSuccessTemplate.vue diff --git a/src/views/ProfileView.spec.ts b/src/views/ProfileView.spec.ts index fb2ade98..66f8727a 100644 --- a/src/views/ProfileView.spec.ts +++ b/src/views/ProfileView.spec.ts @@ -8,7 +8,7 @@ import { type TwoFactorAuthentificationStore, } from '@/stores/TwoFactorAuthentificationStore'; import { DOMWrapper, VueWrapper, mount } from '@vue/test-utils'; -import { beforeEach, describe, expect, test } from 'vitest'; +import { beforeEach, describe, expect, test, type MockInstance } from 'vitest'; import { nextTick } from 'vue'; import { createMemoryHistory, createRouter, useRoute, type Router } from 'vue-router'; import ProfileView from './ProfileView.vue'; @@ -278,6 +278,14 @@ describe('ProfileView', () => { expect(wrapper?.find('[data-testid="profile-headline"]').isVisible()).toBe(true); }); + test('it goes back to the previous page', () => { + const push: MockInstance = vi.spyOn(router, 'push'); + + wrapper?.find('[data-testid="back-to-previous-page-button"]').trigger('click'); + + expect(push).toHaveBeenCalledTimes(1); + }); + test('it displays personal data', () => { personInfoStore.personInfo = mockLehrer; personStore.personenuebersicht = mockLehrerUebersicht; diff --git a/src/views/StartView.spec.ts b/src/views/StartView.spec.ts index 3cf6f91e..58a95402 100644 --- a/src/views/StartView.spec.ts +++ b/src/views/StartView.spec.ts @@ -11,7 +11,7 @@ import { nextTick } from 'vue'; import StartView from './StartView.vue'; import { type PersonStore, usePersonStore, type PersonWithUebersicht } from '@/stores/PersonStore'; import { usePersonInfoStore, type PersonInfoResponse, type PersonInfoStore } from '@/stores/PersonInfoStore'; -import { OrganisationsTyp, RollenArt, RollenMerkmal } from '@/api-client/generated/api'; +import { OrganisationsTyp, RollenArt, RollenMerkmal, ServiceProviderKategorie } from '@/api-client/generated/api'; let wrapper: VueWrapper | null = null; let authStore: AuthStore; @@ -21,16 +21,25 @@ let personInfoStore: PersonInfoStore; const mockProviders: Array = [ { - id: '1', + id: '2', name: 'Spongebob Squarepants', target: 'URL', url: 'https://de.wikipedia.org/wiki/SpongeBob_Schwammkopf', kategorie: 'EMAIL', hasLogo: false, - requires2fa: true, + requires2fa: false, }, { - id: '2', + id: '3', + name: 'Not Squarepants', + target: 'URL', + url: 'https://de.wikipedia.org/wiki/SpongeBob_Schwammkopf', + kategorie: 'EMAIL', + hasLogo: false, + requires2fa: false, + }, + { + id: '1', name: 'Schulportal-Administration', target: 'SCHULPORTAL_ADMINISTRATION', url: '', @@ -170,7 +179,7 @@ describe('StartView', () => { authStore.hasPersonenverwaltungPermission = true; await nextTick(); - const adminCard: WrapperLike | undefined = wrapper?.findComponent('[data-testid="service-provider-card-2"]'); + const adminCard: WrapperLike | undefined = wrapper?.findComponent('[data-testid="service-provider-card-1"]'); expect(adminCard?.isVisible()).toBe(true); expect(adminCard?.attributes('href')).toEqual('/admin/personen'); @@ -180,7 +189,7 @@ describe('StartView', () => { authStore.hasSchulverwaltungPermission = true; await nextTick(); - const adminCard: WrapperLike | undefined = wrapper?.findComponent('[data-testid="service-provider-card-2"]'); + const adminCard: WrapperLike | undefined = wrapper?.findComponent('[data-testid="service-provider-card-1"]'); expect(adminCard?.isVisible()).toBe(true); expect(adminCard?.attributes('href')).toEqual('/admin/schulen'); @@ -190,7 +199,7 @@ describe('StartView', () => { authStore.hasRollenverwaltungPermission = true; await nextTick(); - const adminCard: WrapperLike | undefined = wrapper?.findComponent('[data-testid="service-provider-card-2"]'); + const adminCard: WrapperLike | undefined = wrapper?.findComponent('[data-testid="service-provider-card-1"]'); expect(adminCard?.isVisible()).toBe(true); expect(adminCard?.attributes('href')).toEqual('/admin/rollen'); @@ -200,17 +209,45 @@ describe('StartView', () => { authStore.hasKlassenverwaltungPermission = true; await nextTick(); - const adminCard: WrapperLike | undefined = wrapper?.findComponent('[data-testid="service-provider-card-2"]'); + const adminCard: WrapperLike | undefined = wrapper?.findComponent('[data-testid="service-provider-card-1"]'); expect(adminCard?.isVisible()).toBe(true); expect(adminCard?.attributes('href')).toEqual('/admin/klassen'); }); - test('banner has correct color', async () => { + test('it displays correct banner color', async () => { await nextTick(); const banner: WrapperLike | undefined = wrapper?.find('[data-testid="KOPERS-banner"]'); expect(banner?.classes()).toContain('bg-errorLight'); }); + + test('it dismisses the banner', async () => { + await nextTick(); + const banner: VueWrapper | undefined = wrapper?.findComponent({ ref: 'spsh-banner' }); + + expect(banner?.find('[data-testid="banner-close-icon"]').isVisible()).toBe(true); + banner?.find('[data-testid="banner-close-icon"]').trigger('click'); + await nextTick(); + expect(banner?.emitted('dismissBanner')).toBeTruthy(); + }); + + test('filterSortProviders sorts service providers alphabetically', () => { + serviceProviderStore.availableServiceProviders = mockProviders; + + interface StartViewComponent { + filterSortProviders: (providers: ServiceProvider[], kategorie: ServiceProviderKategorie) => ServiceProvider[]; + } + + const filteredSortProviders: ServiceProvider[] = (wrapper?.vm as unknown as StartViewComponent).filterSortProviders( + mockProviders, + ServiceProviderKategorie.Email, + ); + + expect(filteredSortProviders.map((p: ServiceProvider) => p.name)).toEqual([ + 'Not Squarepants', + 'Spongebob Squarepants', + ]); + }); }); diff --git a/src/views/StartView.vue b/src/views/StartView.vue index f8fff605..b4e6d22a 100644 --- a/src/views/StartView.vue +++ b/src/views/StartView.vue @@ -181,10 +181,11 @@ v-bind:key="alert.id" v-for="alert in alerts" :id="alert.id.toString()" - :visible="alert.visible" + ref="spsh-banner" :text="alert.message" :type="alert.type" - @dismiss-banner="dismissBannerForSession" + :visible="alert.visible" + @dismissBanner="dismissBannerForSession" > diff --git a/src/views/admin/KlasseCreationView.spec.ts b/src/views/admin/KlasseCreationView.spec.ts index 8a287f33..06e133af 100644 --- a/src/views/admin/KlasseCreationView.spec.ts +++ b/src/views/admin/KlasseCreationView.spec.ts @@ -4,14 +4,51 @@ import { useAuthStore, type AuthStore, type UserInfo } from '@/stores/AuthStore' import { useOrganisationStore, type OrganisationStore } from '@/stores/OrganisationStore'; import { usePersonStore, type PersonStore } from '@/stores/PersonStore'; import { VueWrapper, flushPromises, mount } from '@vue/test-utils'; -import { beforeEach, describe, expect, test, vi, type MockInstance } from 'vitest'; +import { beforeEach, describe, expect, test, vi, type Mock, type MockInstance } from 'vitest'; import { nextTick } from 'vue'; -import { createRouter, createWebHistory, type Router } from 'vue-router'; +import { + createRouter, + createWebHistory, + type NavigationGuardNext, + type RouteLocationNormalized, + type Router, +} from 'vue-router'; import KlasseCreationView from './KlasseCreationView.vue'; +import type Module from 'module'; let wrapper: VueWrapper | null = null; let router: Router; -let organisationStore: OrganisationStore; +const organisationStore: OrganisationStore = useOrganisationStore(); + +type OnBeforeRouteLeaveCallback = ( + _to: RouteLocationNormalized, + _from: RouteLocationNormalized, + _next: NavigationGuardNext, +) => void; + +let { storedBeforeRouteLeaveCallback }: { storedBeforeRouteLeaveCallback: OnBeforeRouteLeaveCallback } = vi.hoisted( + () => { + return { + storedBeforeRouteLeaveCallback: ( + _to: RouteLocationNormalized, + _from: RouteLocationNormalized, + _next: NavigationGuardNext, + ): void => {}, + }; + }, +); + +organisationStore.allOrganisationen = [ + { + id: '1', + name: 'Albert-Emil-Hansebrot-Gymnasium', + kennung: '9356494', + namensergaenzung: 'Schule', + kuerzel: 'aehg', + typ: 'SCHULE', + administriertVon: '1', + }, +]; function mountComponent(): VueWrapper { return mount(KlasseCreationView, { @@ -20,25 +57,55 @@ function mountComponent(): VueWrapper { components: { KlasseCreationView, }, - mocks: { - route: { - fullPath: 'full/path', - }, - }, plugins: [router], }, }); } +type FormFields = { + schule: string; + klassenname: string; +}; + +type FormSelectors = { + schuleSelect: VueWrapper; + klassennameInput: VueWrapper; +}; + +async function fillForm(args: Partial): Promise> { + const { schule, klassenname }: Partial = args; + const selectors: Partial = {}; + + const schuleSelect: VueWrapper | undefined = wrapper + ?.findComponent({ ref: 'klasse-creation-form' }) + .findComponent({ ref: 'schule-select' }); + expect(schuleSelect?.exists()).toBe(true); + + await schuleSelect?.setValue(schule); + await nextTick(); + selectors.schuleSelect = schuleSelect; + + const klassennameInput: VueWrapper | undefined = wrapper + ?.findComponent({ ref: 'klasse-creation-form' }) + .findComponent({ ref: 'klassenname-input' }); + expect(klassennameInput?.exists()).toBe(true); + + await klassennameInput?.setValue(klassenname); + await nextTick(); + selectors.klassennameInput = klassennameInput; + + return selectors; +} + beforeEach(async () => { document.body.innerHTML = `
-
+ +
+
`; - organisationStore = useOrganisationStore(); - router = createRouter({ history: createWebHistory(), routes, @@ -47,25 +114,13 @@ beforeEach(async () => { router.push('/'); await router.isReady(); - organisationStore.allOrganisationen = [ - { - id: '1', - name: 'Albert-Emil-Hansebrot-Gymnasium', - kennung: '9356494', - namensergaenzung: 'Schule', - kuerzel: 'aehg', - typ: 'SCHULE', - administriertVon: '1', - }, - ]; - wrapper = mountComponent(); + organisationStore.errorCode = ''; + organisationStore.createdKlasse = null; }); afterEach(() => { - // Reset the organisationStore state after each test - organisationStore.errorCode = ''; - organisationStore.createdKlasse = null; + wrapper?.unmount(); }); describe('KlasseCreationView', () => { @@ -86,17 +141,23 @@ describe('KlasseCreationView', () => { expect(push).toHaveBeenCalledTimes(1); }); - test('it fills form and triggers submit', async () => { - const schuleSelect: VueWrapper | undefined = wrapper - ?.findComponent({ ref: 'klasse-creation-form' }) - .findComponent({ ref: 'schule-select' }); - await schuleSelect?.setValue('1'); - await nextTick(); + test('it cancels editing', async () => { + const push: MockInstance = vi.spyOn(router, 'push'); + organisationStore.createdKlasse = null; + organisationStore.errorCode = ''; - const klassennameInput: VueWrapper | undefined = wrapper - ?.findComponent({ ref: 'klasse-creation-form' }) - .findComponent({ ref: 'klassenname-input' }); - await klassennameInput?.setValue('11b'); + await wrapper?.find('[data-testid="klasse-form-discard-button"]').trigger('click'); + await flushPromises(); + + expect(document.querySelector('[data-testid="unsaved-changes-warning-text"]')).toBeNull(); + expect(push).toHaveBeenCalledTimes(1); + }); + + test('it fills form and triggers submit', async () => { + await fillForm({ + schule: '1', + klassenname: '11b', + }); await nextTick(); const mockKlasse: OrganisationResponse = { @@ -122,24 +183,99 @@ describe('KlasseCreationView', () => { expect(organisationStore.createdKlasse).toBe(null); }); - test('it shows error message', async () => { - organisationStore.errorCode = 'KLASSENNAME_AN_SCHULE_EINDEUTIG'; - await nextTick(); + describe('navigation interception', () => { + afterEach(() => { + vi.unmock('vue-router'); + }); - expect(wrapper?.find('[data-testid="alert-title"]').isVisible()).toBe(true); + test('it triggers if form is dirty', async () => { + const expectedCallsToNext: number = 0; + vi.mock('vue-router', async (importOriginal: () => Promise) => { + const mod: Module = await importOriginal(); + return { + ...mod, + onBeforeRouteLeave: vi.fn((actualCallback: OnBeforeRouteLeaveCallback) => { + storedBeforeRouteLeaveCallback = actualCallback; + }), + }; + }); + wrapper = mountComponent(); + await fillForm({ + schule: '1', + klassenname: '11b', + }); - wrapper?.find('[data-testid="alert-button"]').trigger('click'); - await nextTick(); + const spy: Mock = vi.fn(); + storedBeforeRouteLeaveCallback({} as RouteLocationNormalized, {} as RouteLocationNormalized, spy); + expect(spy).toHaveBeenCalledTimes(expectedCallsToNext); + await nextTick(); - expect(organisationStore.errorCode).toBe(''); + const confirmButton: Element | null = document.querySelector('[data-testid="confirm-unsaved-changes-button"]'); + expect(confirmButton).not.toBeNull(); + confirmButton!.dispatchEvent(new Event('click')); + expect(spy).toHaveBeenCalledOnce(); + }); + + test('it does not trigger if form is not dirty', async () => { + const expectedCallsToNext: number = 1; + vi.mock('vue-router', async (importOriginal: () => Promise) => { + const mod: Module = await importOriginal(); + return { + ...mod, + onBeforeRouteLeave: vi.fn((actualCallback: OnBeforeRouteLeaveCallback) => { + storedBeforeRouteLeaveCallback = actualCallback; + }), + }; + }); + wrapper = mountComponent(); + const spy: Mock = vi.fn(); + storedBeforeRouteLeaveCallback({} as RouteLocationNormalized, {} as RouteLocationNormalized, spy); + expect(spy).toHaveBeenCalledTimes(expectedCallsToNext); + }); }); - test('shows error message if REQUIRED_STEP_UP_LEVEL_NOT_MET error is present and click close button', async () => { - organisationStore.errorCode = 'REQUIRED_STEP_UP_LEVEL_NOT_MET'; - await nextTick(); - expect(wrapper?.find('[data-testid="alert-title"]').isVisible()).toBe(true); - wrapper?.find('[data-testid="alert-button"]').trigger('click'); - await nextTick(); + describe.each([[true], [false]])('when form is dirty:%s', async (isFormDirty: boolean) => { + beforeEach(async () => { + if (isFormDirty) + await fillForm({ + schule: '1', + klassenname: '11b', + }); + }); + + test('it handles unloading', async () => { + const event: Event = new Event('beforeunload'); + const spy: MockInstance = vi.spyOn(event, 'preventDefault'); + window.dispatchEvent(event); + if (isFormDirty) expect(spy).toHaveBeenCalledOnce(); + else expect(spy).not.toHaveBeenCalledOnce(); + }); + }); + + describe('error handling', () => { + test('it shows error message', async () => { + organisationStore.errorCode = 'KLASSENNAME_AN_SCHULE_EINDEUTIG'; + await nextTick(); + + expect(wrapper?.find('[data-testid="alert-title"]').isVisible()).toBe(true); + + wrapper?.find('[data-testid="alert-button"]').trigger('click'); + await nextTick(); + + expect(organisationStore.errorCode).toBe(''); + }); + + test('shows error message if REQUIRED_STEP_UP_LEVEL_NOT_MET error is present and click close button', async () => { + organisationStore.errorCode = 'REQUIRED_STEP_UP_LEVEL_NOT_MET'; + await nextTick(); + + expect(wrapper?.find('[data-testid="alert-title"]').isVisible()).toBe(true); + + wrapper?.find('[data-testid="alert-button"]').trigger('click'); + await nextTick(); + + organisationStore.errorCode = ''; + }); }); describe('autoselect logic', () => { diff --git a/src/views/admin/KlasseCreationView.vue b/src/views/admin/KlasseCreationView.vue index a5be5932..cc7a0820 100644 --- a/src/views/admin/KlasseCreationView.vue +++ b/src/views/admin/KlasseCreationView.vue @@ -1,6 +1,6 @@