From 3498c6c30ef6eeac0ffaa837cd228962641d1d80 Mon Sep 17 00:00:00 2001
From: WilliamThorenfeldt
<133344438+WilliamThorenfeldt@users.noreply.github.com>
Date: Mon, 5 Feb 2024 19:18:09 +0100
Subject: [PATCH 1/7] 12138 playwright for settings modal (#12196)
* Moving all e2e tests from Cypress to Playwright
* initial commit
* initial commit
* creating overview page
* fixing typo
* fixing feedback from PR
* almost implementing all tests
* Finalinsing navigation Playwright test
* fix cypress
* fix
* fixing cypress
* initial commit
* implementing header class
* adding more
* adding more gitea and header logic
* adding functionality for downloading repo
* Finalising Git Cypress
* initial commit
* Completing SettingsModal Playwright tests
* fixing unit tests
* adding more delay to playwright tests
* fixing feedback on PR
* fixing broken tests
* fixing update org
* merge master
* deleting old cypress
* fixing feedback from PR
---
.../SettingsModalButton.test.tsx | 4 +-
.../SettingsModalButton.tsx | 2 +-
.../src/integration/studio/settingsModal.js | 75 ----------
.../cypress/src/selectors/accessControlTab.js | 17 ---
.../src/selectors/administrationTab.js | 7 -
.../cypress/src/selectors/policyEditorTab.js | 8 --
.../cypress/src/selectors/settingsTab.js | 16 ---
.../testing/playwright/components/Header.ts | 4 +
.../playwright/components/PolicyEditor.ts | 33 +++++
.../playwright/components/SettingsModal.ts | 58 ++++++++
frontend/testing/playwright/enum/AppNames.ts | 1 +
frontend/testing/playwright/enum/TestNames.ts | 1 +
.../testing/playwright/playwright.config.ts | 14 +-
.../settings-modal/settings-modal.spec.ts | 129 ++++++++++++++++++
14 files changed, 242 insertions(+), 127 deletions(-)
delete mode 100644 frontend/testing/cypress/src/integration/studio/settingsModal.js
delete mode 100644 frontend/testing/cypress/src/selectors/accessControlTab.js
delete mode 100644 frontend/testing/cypress/src/selectors/administrationTab.js
delete mode 100644 frontend/testing/cypress/src/selectors/policyEditorTab.js
delete mode 100644 frontend/testing/cypress/src/selectors/settingsTab.js
create mode 100644 frontend/testing/playwright/components/PolicyEditor.ts
create mode 100644 frontend/testing/playwright/components/SettingsModal.ts
create mode 100644 frontend/testing/playwright/tests/settings-modal/settings-modal.spec.ts
diff --git a/frontend/app-development/layout/SettingsModalButton/SettingsModalButton.test.tsx b/frontend/app-development/layout/SettingsModalButton/SettingsModalButton.test.tsx
index 3db79347158..1146acb3a24 100644
--- a/frontend/app-development/layout/SettingsModalButton/SettingsModalButton.test.tsx
+++ b/frontend/app-development/layout/SettingsModalButton/SettingsModalButton.test.tsx
@@ -43,7 +43,7 @@ describe('SettingsModal', () => {
});
expect(modalHeading).not.toBeInTheDocument();
- const button = screen.getByRole('button', { name: textMock('settings_modal.heading') });
+ const button = screen.getByRole('button', { name: textMock('sync_header.settings') });
await act(() => user.click(button));
const modalHeadingAfter = screen.getByRole('heading', {
@@ -55,7 +55,7 @@ describe('SettingsModal', () => {
it('closes the SettingsModal when the modal is closed', async () => {
render();
- const button = screen.getByRole('button', { name: textMock('settings_modal.heading') });
+ const button = screen.getByRole('button', { name: textMock('sync_header.settings') });
await act(() => user.click(button));
const modalHeading = screen.getByRole('heading', {
diff --git a/frontend/app-development/layout/SettingsModalButton/SettingsModalButton.tsx b/frontend/app-development/layout/SettingsModalButton/SettingsModalButton.tsx
index 3e691df6ecd..617f1e489de 100644
--- a/frontend/app-development/layout/SettingsModalButton/SettingsModalButton.tsx
+++ b/frontend/app-development/layout/SettingsModalButton/SettingsModalButton.tsx
@@ -27,7 +27,7 @@ export const SettingsModalButton = ({ org, app }: SettingsModalButtonProps): Rea
color='inverted'
icon={}
>
- {t('settings_modal.heading')}
+ {t('sync_header.settings')}
{
// Done to prevent API calls to be executed before the modal is open
diff --git a/frontend/testing/cypress/src/integration/studio/settingsModal.js b/frontend/testing/cypress/src/integration/studio/settingsModal.js
deleted file mode 100644
index 1c1e556a818..00000000000
--- a/frontend/testing/cypress/src/integration/studio/settingsModal.js
+++ /dev/null
@@ -1,75 +0,0 @@
-///
-///
-
-import * as texts from '../../../../../language/src/nb.json';
-import { accessControlTab } from '../../selectors/accessControlTab';
-import { administrationTab } from '../../selectors/administrationTab';
-import { policyEditorTab } from '../../selectors/policyEditorTab';
-import { settingsTab } from '../../selectors/settingsTab';
-
-const designerAppId = `${Cypress.env('autoTestUser')}/${Cypress.env('designerAppName')}`;
-
-context('SettingsModal', () => {
- before(() => {
- cy.deleteAllApps(Cypress.env('autoTestUser'), Cypress.env('accessToken'));
- cy.studioLogin(Cypress.env('autoTestUser'), Cypress.env('autoTestUserPwd'));
- cy.createApp(Cypress.env('autoTestUser'), Cypress.env('designerAppName'));
- });
- beforeEach(() => {
- cy.visit('/dashboard');
- // Navigate to designerApp
- cy.visit('/editor/' + designerAppId);
- cy.openSettingsModal();
- });
- after(() => {
- cy.deleteAllApps(Cypress.env('autoTestUser'), Cypress.env('accessToken'));
- });
-
- it('is possible to open the settings modal', () => {
- cy.findByRole('heading', { name: texts['settings_modal.heading'] }).should('be.visible');
- });
-
- it('is possible to close the settings modal', () => {
- cy.findByRole('button', { name: texts['modal.close_icon'] }).click();
- cy.findByRole('heading', { name: texts['settings_modal.heading'] }).should('not.exist');
- });
-
- it('is possible to see and edit information on About App tab', () => {
- administrationTab.getHeader().should('be.visible');
- administrationTab.getAppNameField().clear().type('New app name');
- administrationTab.getAppNameField().invoke('val').should('contain', 'New app name');
- });
-
- it('is possible to toggle settings on app settings tab', () => {
- settingsTab.getTab().click();
- settingsTab.getHeader().should('be.visible');
- settingsTab.getAutoDelete().should('be.visible');
- settingsTab.getEnableCopyInstance().should('be.visible');
- settingsTab.getHideInInbox().should('be.visible');
- settingsTab.getShowStartedInstances().should('be.visible');
- });
-
- it('is possible to load the policy editor tab', () => {
- // This test only loads the tab and tests that it loads as expected.
- // We should implement a separate test for the poloicy editor.
- policyEditorTab.getTab().click();
- policyEditorTab.getHeader().should('be.visible');
- policyEditorTab.getSecurityLevelSelect().should('be.visible');
- });
-
- it('is possible to update settings on the access control tab', () => {
- accessControlTab.getTab().click();
- accessControlTab.getHeader().should('be.visible');
- accessControlTab.getOrganisationParty().should('be.visible');
- accessControlTab.getPersonParty().should('be.visible');
- accessControlTab.getSubUnitParty().should('be.visible');
- accessControlTab.getBankruptcyParty().should('be.visible').click();
- accessControlTab.getBankruptcyPartyCheckbox().should('be.checked');
-
- // Close modal and re-open to confirm data is set as expected
- cy.findByRole('button', { name: texts['modal.close_icon'] }).click();
- cy.openSettingsModal();
- accessControlTab.getTab().click();
- accessControlTab.getBankruptcyPartyCheckbox().should('be.checked');
- });
-});
diff --git a/frontend/testing/cypress/src/selectors/accessControlTab.js b/frontend/testing/cypress/src/selectors/accessControlTab.js
deleted file mode 100644
index c147c1934d1..00000000000
--- a/frontend/testing/cypress/src/selectors/accessControlTab.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import * as texts from '@altinn-studio/language/src/nb.json';
-
-export const accessControlTab = {
- getHeader: () =>
- cy.findByRole('heading', { name: texts['settings_modal.access_control_tab_heading'] }),
- getTab: () => cy.findByText(texts['settings_modal.access_control_tab_heading']),
- getBankruptcyParty: () =>
- cy.findByText(texts['settings_modal.access_control_tab_option_bankruptcy_estate']),
- getBankruptcyPartyCheckbox: () =>
- cy.findByRole('checkbox', {
- name: texts['settings_modal.access_control_tab_option_bankruptcy_estate'],
- }),
- getOrganisationParty: () =>
- cy.findByText(texts['settings_modal.access_control_tab_option_organisation']),
- getPersonParty: () => cy.findByText(texts['settings_modal.access_control_tab_option_person']),
- getSubUnitParty: () => cy.findByText(texts['settings_modal.access_control_tab_option_sub_unit']),
-};
diff --git a/frontend/testing/cypress/src/selectors/administrationTab.js b/frontend/testing/cypress/src/selectors/administrationTab.js
deleted file mode 100644
index 3a68cb833c2..00000000000
--- a/frontend/testing/cypress/src/selectors/administrationTab.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import * as texts from '@altinn-studio/language/src/nb.json';
-
-export const administrationTab = {
- getAppNameField: () =>
- cy.findByRole('textbox', { name: texts['settings_modal.about_tab_name_label'] }),
- getHeader: () => cy.findByRole('heading', { name: texts['settings_modal.about_tab_heading'] }),
-};
diff --git a/frontend/testing/cypress/src/selectors/policyEditorTab.js b/frontend/testing/cypress/src/selectors/policyEditorTab.js
deleted file mode 100644
index 379ac85010f..00000000000
--- a/frontend/testing/cypress/src/selectors/policyEditorTab.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import * as texts from '@altinn-studio/language/src/nb.json';
-
-export const policyEditorTab = {
- getHeader: () => cy.findByRole('heading', { name: texts['settings_modal.policy_tab_heading'] }),
- getTab: () => cy.findByText(texts['settings_modal.policy_tab_heading']),
- getSecurityLevelSelect: () =>
- cy.findByRole('combobox', { name: texts['policy_editor.select_auth_level_label'] }),
-};
diff --git a/frontend/testing/cypress/src/selectors/settingsTab.js b/frontend/testing/cypress/src/selectors/settingsTab.js
deleted file mode 100644
index af07a6b2aed..00000000000
--- a/frontend/testing/cypress/src/selectors/settingsTab.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import * as texts from '@altinn-studio/language/src/nb.json';
-
-export const settingsTab = {
- getHeader: () => cy.findByRole('heading', { name: texts['settings_modal.setup_tab_heading'] }),
- getTab: () => cy.findByText(texts['settings_modal.setup_tab_heading']),
- getAutoDelete: () =>
- cy.findByText(texts['settings_modal.setup_tab_switch_autoDeleteOnProcessEnd']),
- getHideInInbox: () =>
- cy.findByText(
- texts['settings_modal.setup_tab_switch_messageBoxConfig_hideSettings_hideAlways'],
- ),
- getEnableCopyInstance: () =>
- cy.findByText(texts['settings_modal.setup_tab_switch_copyInstanceSettings_enabled']),
- getShowStartedInstances: () =>
- cy.findByText(texts['settings_modal.setup_tab_switch_onEntry_show']),
-};
diff --git a/frontend/testing/playwright/components/Header.ts b/frontend/testing/playwright/components/Header.ts
index 8623ded5e16..ed42474b70d 100644
--- a/frontend/testing/playwright/components/Header.ts
+++ b/frontend/testing/playwright/components/Header.ts
@@ -61,4 +61,8 @@ export class Header extends BasePage {
.getByRole('button', { name: this.textMock('sync_header.local_changes') })
.click();
}
+
+ public async clickOnOpenSettingsModalButton(): Promise {
+ await this.page.getByRole('button', { name: this.textMock('sync_header.settings') }).click();
+ }
}
diff --git a/frontend/testing/playwright/components/PolicyEditor.ts b/frontend/testing/playwright/components/PolicyEditor.ts
new file mode 100644
index 00000000000..97a9c2ff69d
--- /dev/null
+++ b/frontend/testing/playwright/components/PolicyEditor.ts
@@ -0,0 +1,33 @@
+import { BasePage } from '../helpers/BasePage';
+import type { Environment } from '../helpers/StudioEnvironment';
+import type { Page } from '@playwright/test';
+
+type SecurityLevel = 0 | 1 | 2 | 3 | 4;
+
+export class PolicyEditor extends BasePage {
+ constructor(page: Page, environment?: Environment) {
+ super(page, environment);
+ }
+
+ public async getSelectedSecurityLevel(): Promise {
+ return await this.page
+ .getByRole('combobox', {
+ name: this.textMock('policy_editor.select_auth_level_label'),
+ })
+ .inputValue();
+ }
+
+ public getSecurityLevelByTextByLevel(level: SecurityLevel): string {
+ return this.textMock(`policy_editor.auth_level_option_${level}`);
+ }
+
+ public async clickOnSecurityLevelSelect(): Promise {
+ await this.page.getByLabel(this.textMock('policy_editor.select_auth_level_label')).click();
+ }
+
+ public async clickOnSecurityLevelSelectOption(level: SecurityLevel): Promise {
+ await this.page
+ .getByRole('option', { name: this.textMock(`policy_editor.auth_level_option_${level}`) })
+ .click();
+ }
+}
diff --git a/frontend/testing/playwright/components/SettingsModal.ts b/frontend/testing/playwright/components/SettingsModal.ts
new file mode 100644
index 00000000000..2860c875098
--- /dev/null
+++ b/frontend/testing/playwright/components/SettingsModal.ts
@@ -0,0 +1,58 @@
+import { BasePage } from '../helpers/BasePage';
+import type { Environment } from '../helpers/StudioEnvironment';
+import type { Page } from '@playwright/test';
+
+export type SettingsModalTab = 'about' | 'accessControl' | 'policy' | 'setup';
+type SettingsModalTabHeading = 'about' | 'access_control' | 'policy' | 'setup';
+
+export class SettingsModal extends BasePage {
+ constructor(page: Page, environment?: Environment) {
+ super(page, environment);
+ }
+
+ public async verifyThatSettingsModalIsOpen(): Promise {
+ await this.page
+ .getByRole('heading', {
+ name: this.textMock('settings_modal.heading'),
+ level: 1,
+ })
+ .isVisible();
+ }
+
+ public async clickOnCloseSettingsModalButton(): Promise {
+ await this.page.getByRole('button', { name: this.textMock('modal.close_icon') }).click();
+ }
+
+ public async verifyThatSettingsModalIsNotOpen(): Promise {
+ await this.page
+ .getByRole('heading', {
+ name: this.textMock('settings_modal.heading'),
+ level: 1,
+ })
+ .isHidden();
+ }
+
+ public async navigateToTab(tab: SettingsModalTab): Promise {
+ await this.page
+ .getByRole('tab', { name: this.textMock(`settings_modal.left_nav_tab_${tab}`) })
+ .click();
+ }
+
+ public async verifyThatTabIsVisible(tabHeading: SettingsModalTabHeading): Promise {
+ await this.page
+ .getByRole('heading', {
+ name: this.textMock(`settings_modal.${tabHeading}_tab_heading`),
+ level: 2,
+ })
+ .isVisible();
+ }
+
+ public async verifyThatTabIsHidden(tabHeading: SettingsModalTabHeading): Promise {
+ await this.page
+ .getByRole('heading', {
+ name: this.textMock(`settings_modal.${tabHeading}_tab_heading`),
+ level: 2,
+ })
+ .isHidden();
+ }
+}
diff --git a/frontend/testing/playwright/enum/AppNames.ts b/frontend/testing/playwright/enum/AppNames.ts
index 1721b14cfbb..2c11b95a1a3 100644
--- a/frontend/testing/playwright/enum/AppNames.ts
+++ b/frontend/testing/playwright/enum/AppNames.ts
@@ -4,5 +4,6 @@ export enum AppNames {
DASHBOARD_APP = 'dashboard-app-test',
GIT_SYNC_APP = 'git-sync-app-test',
MAIN_NAVIGATION_APP = 'navigation-app-test',
+ SETTINGS_MODAL_APP = 'settings-modal-app-test',
UI_EDITOR_APP = 'ui-editor-app-test',
}
diff --git a/frontend/testing/playwright/enum/TestNames.ts b/frontend/testing/playwright/enum/TestNames.ts
index 2cfd654fce8..266747e9f9e 100644
--- a/frontend/testing/playwright/enum/TestNames.ts
+++ b/frontend/testing/playwright/enum/TestNames.ts
@@ -7,4 +7,5 @@ export enum TestNames {
MAIN_NAVIGATION_BETWEEN_SUB_APPS = 'main-navigation-between-sub-apps',
UI_EDITOR = 'ui-editor',
GIT_SYNC = 'git-sync',
+ SETTINGS_MODAL = 'settings-modal',
}
diff --git a/frontend/testing/playwright/playwright.config.ts b/frontend/testing/playwright/playwright.config.ts
index 8f0d8ef3b2b..2bdaa95d35a 100644
--- a/frontend/testing/playwright/playwright.config.ts
+++ b/frontend/testing/playwright/playwright.config.ts
@@ -89,12 +89,23 @@ export default defineConfig({
testMatch: '*.spec.ts',
use: {
...devices['Desktop Chrome'],
- baseURL: process.env.PLAYWRIGHT_TEST_BASE_URL,
storageState: '.playwright/auth/user.json',
testAppName: AppNames.UI_EDITOR_APP,
headless: true,
},
},
+ {
+ name: TestNames.SETTINGS_MODAL,
+ dependencies: ['setup'],
+ testDir: './tests/settings-modal/',
+ testMatch: '*.spec.ts',
+ use: {
+ ...devices['Desktop Chrome'],
+ storageState: '.playwright/auth/user.json',
+ testAppName: AppNames.SETTINGS_MODAL_APP,
+ headless: true,
+ },
+ },
{
name: TestNames.LOGOUT_AND_INVALID_LOGIN_ONLY,
// Add ALL other test names here to make sure that the log out test is the last test to be executed
@@ -106,6 +117,7 @@ export default defineConfig({
TestNames.MAIN_NAVIGATION_BETWEEN_SUB_APPS,
TestNames.GIT_SYNC,
TestNames.UI_EDITOR,
+ TestNames.SETTINGS_MODAL,
],
testDir: './tests/logout-and-invalid-login-only/',
testMatch: '*.spec.ts',
diff --git a/frontend/testing/playwright/tests/settings-modal/settings-modal.spec.ts b/frontend/testing/playwright/tests/settings-modal/settings-modal.spec.ts
new file mode 100644
index 00000000000..935e11842c3
--- /dev/null
+++ b/frontend/testing/playwright/tests/settings-modal/settings-modal.spec.ts
@@ -0,0 +1,129 @@
+import { expect } from '@playwright/test';
+import type { Page } from '@playwright/test';
+import { test } from '../../extenders/testExtend';
+import { DesignerApi } from '../../helpers/DesignerApi';
+import type { StorageState } from '../../types/StorageState';
+import { Header } from '../../components/Header';
+import { UiEditorPage } from '../../pages/UiEditorPage';
+import { SettingsModal } from '../../components/SettingsModal';
+import type { SettingsModalTab } from '../../components/SettingsModal';
+import { PolicyEditor } from '../../components/PolicyEditor';
+import { Gitea } from 'testing/playwright/helpers/Gitea';
+
+// This line must be there to ensure that the tests do not run in parallell, and
+// that the before all call is being executed before we start the tests
+test.describe.configure({ mode: 'serial' });
+
+// Before the tests starts, we need to create the data model app
+test.beforeAll(async ({ testAppName, request, storageState }) => {
+ // Create a new app
+ const designerApi = new DesignerApi({ app: testAppName });
+ const response = await designerApi.createApp(request, storageState as StorageState);
+ expect(response.ok()).toBeTruthy();
+});
+
+test.afterAll(async ({ request, testAppName }) => {
+ const gitea = new Gitea();
+ const response = await request.delete(gitea.getDeleteAppEndpoint({ app: testAppName }));
+ expect(response.ok()).toBeTruthy();
+});
+
+const setupAndVerifyUiEditorPage = async (page: Page, testAppName: string): Promise => {
+ const uiEditorPage = new UiEditorPage(page, { app: testAppName });
+ await uiEditorPage.loadUiEditorPage();
+ await uiEditorPage.verifyUiEditorPage();
+};
+
+const setUpAndOpenSettingsModal = async (
+ page: Page,
+ testAppName: string,
+ tabToStartAt: SettingsModalTab = 'about',
+): Promise => {
+ const settingsModal = new SettingsModal(page, { app: testAppName });
+ const header = new Header(page, { app: testAppName });
+
+ await setupAndVerifyUiEditorPage(page, testAppName);
+
+ await header.clickOnOpenSettingsModalButton();
+ await settingsModal.verifyThatSettingsModalIsOpen();
+
+ if (tabToStartAt !== 'about') {
+ settingsModal.navigateToTab(tabToStartAt);
+ }
+
+ return settingsModal;
+};
+
+test('That it is possible to change tab from "About app" tab to "Setup" tab', async ({
+ page,
+ testAppName,
+}) => {
+ const settingsModal = await setUpAndOpenSettingsModal(page, testAppName);
+
+ await settingsModal.verifyThatTabIsVisible('about');
+ await settingsModal.verifyThatTabIsHidden('setup');
+
+ await settingsModal.navigateToTab('setup');
+
+ await settingsModal.verifyThatTabIsHidden('about');
+ await settingsModal.verifyThatTabIsVisible('setup');
+});
+
+test('That it is possible to change tab to "Policy editor" tab', async ({ page, testAppName }) => {
+ const settingsModal = await setUpAndOpenSettingsModal(page, testAppName, 'setup');
+
+ await settingsModal.verifyThatTabIsVisible('setup');
+ await settingsModal.verifyThatTabIsHidden('policy');
+
+ await settingsModal.navigateToTab('policy');
+
+ await settingsModal.verifyThatTabIsHidden('setup');
+ await settingsModal.verifyThatTabIsVisible('policy');
+});
+
+test('That it is possible to edit security level on "Policy editor" tab, and that changes are saved', async ({
+ page,
+ testAppName,
+}) => {
+ const settingsModal = await setUpAndOpenSettingsModal(page, testAppName, 'policy');
+ await settingsModal.verifyThatTabIsVisible('policy');
+
+ const policyEditor = new PolicyEditor(page, { app: testAppName });
+
+ const securityLevel2 = 2;
+ const securityLevel2Text = policyEditor.getSecurityLevelByTextByLevel(securityLevel2);
+ expect(await policyEditor.getSelectedSecurityLevel()).toEqual(securityLevel2Text);
+
+ await policyEditor.clickOnSecurityLevelSelect();
+ await policyEditor.clickOnSecurityLevelSelectOption(3);
+
+ const securityLevel3 = 3;
+ const securityLevel3Text = policyEditor.getSecurityLevelByTextByLevel(securityLevel3);
+ expect(await policyEditor.getSelectedSecurityLevel()).toEqual(securityLevel3Text);
+
+ await settingsModal.navigateToTab('about');
+ await settingsModal.verifyThatTabIsVisible('about');
+ await settingsModal.verifyThatTabIsHidden('policy');
+
+ await settingsModal.navigateToTab('policy');
+ expect(await policyEditor.getSelectedSecurityLevel()).toEqual(securityLevel3Text);
+});
+
+test('That it is possible to change tab to "Access control" tab', async ({ page, testAppName }) => {
+ const settingsModal = await setUpAndOpenSettingsModal(page, testAppName, 'policy');
+
+ await settingsModal.verifyThatTabIsVisible('policy');
+ await settingsModal.verifyThatTabIsHidden('access_control');
+
+ await settingsModal.navigateToTab('accessControl');
+
+ await settingsModal.verifyThatTabIsHidden('policy');
+ await settingsModal.verifyThatTabIsVisible('access_control');
+});
+
+test('That it is possible to close the settings modal', async ({ page, testAppName }) => {
+ const settingsModal = await setUpAndOpenSettingsModal(page, testAppName);
+
+ await settingsModal.clickOnCloseSettingsModalButton();
+ await settingsModal.verifyThatSettingsModalIsNotOpen();
+});
From b5fb51e5277a11804cefc2168dfe4cd6d011d431 Mon Sep 17 00:00:00 2001
From: Mirko Sekulic
Date: Mon, 5 Feb 2024 21:10:37 +0100
Subject: [PATCH 2/7] Proccess file sync (#12246)
* Add MediatR package
* event and event handlers structure added
* fix endpoint and add tests
* delete taskName change endpoint
---
backend/packagegroups/NuGet.props | 1 +
.../Controllers/AppDevelopmentController.cs | 1 +
.../Controllers/ProcessModelingController.cs | 88 +++++++++----
backend/src/Designer/Designer.csproj | 1 +
...TaskIdChangedApplicationMetadataHandler.cs | 16 +++
.../Events/ProcessTaskIdChangedEvent.cs | 9 ++
.../Models/Dto/ProcessDefinitionMetadata.cs | 8 ++
.../src/Designer/Models/Dto/TaskIdChange.cs | 7 +
.../Dto}/VersionResponse.cs | 2 +-
backend/src/Designer/Program.cs | 4 +
.../ProcessModeling/ProcessModelingService.cs | 31 -----
.../Interfaces/IProcessModelingService.cs | 10 --
.../GetAppVersionTests.cs | 1 +
.../UpdateProcessTaskNameTests.cs | 54 --------
.../UpsertProcessDefinitionAndNotifyTests.cs | 65 +++++++++
.../Services/ProcessModelingServiceTests.cs | 28 ----
testdata/App/config/process/process.bpmn | 124 +++++++++++-------
17 files changed, 251 insertions(+), 199 deletions(-)
create mode 100644 backend/src/Designer/EventHandlers/ProcessTaskIdChanged/ProcessTaskIdChangedApplicationMetadataHandler.cs
create mode 100644 backend/src/Designer/Events/ProcessTaskIdChangedEvent.cs
create mode 100644 backend/src/Designer/Models/Dto/ProcessDefinitionMetadata.cs
create mode 100644 backend/src/Designer/Models/Dto/TaskIdChange.cs
rename backend/src/Designer/{ViewModels/Response => Models/Dto}/VersionResponse.cs (87%)
delete mode 100644 backend/tests/Designer.Tests/Controllers/ProcessModelingController/UpdateProcessTaskNameTests.cs
create mode 100644 backend/tests/Designer.Tests/Controllers/ProcessModelingController/UpsertProcessDefinitionAndNotifyTests.cs
diff --git a/backend/packagegroups/NuGet.props b/backend/packagegroups/NuGet.props
index 80caac4ea61..d4964c9ce35 100644
--- a/backend/packagegroups/NuGet.props
+++ b/backend/packagegroups/NuGet.props
@@ -37,6 +37,7 @@
+
diff --git a/backend/src/Designer/Controllers/AppDevelopmentController.cs b/backend/src/Designer/Controllers/AppDevelopmentController.cs
index 5fbc871c510..ddff9e403d3 100644
--- a/backend/src/Designer/Controllers/AppDevelopmentController.cs
+++ b/backend/src/Designer/Controllers/AppDevelopmentController.cs
@@ -10,6 +10,7 @@
using Altinn.Studio.Designer.Helpers;
using Altinn.Studio.Designer.Infrastructure.GitRepository;
using Altinn.Studio.Designer.Models;
+using Altinn.Studio.Designer.Models.Dto;
using Altinn.Studio.Designer.Services.Interfaces;
using Altinn.Studio.Designer.ViewModels.Response;
using Microsoft.AspNetCore.Authorization;
diff --git a/backend/src/Designer/Controllers/ProcessModelingController.cs b/backend/src/Designer/Controllers/ProcessModelingController.cs
index 94361b99ff2..8a3e237ed13 100644
--- a/backend/src/Designer/Controllers/ProcessModelingController.cs
+++ b/backend/src/Designer/Controllers/ProcessModelingController.cs
@@ -2,11 +2,15 @@
using System.Collections.Generic;
using System.IO;
using System.Net.Mime;
+using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
+using Altinn.Studio.Designer.Events;
using Altinn.Studio.Designer.Helpers;
using Altinn.Studio.Designer.Models;
+using Altinn.Studio.Designer.Models.Dto;
using Altinn.Studio.Designer.Services.Interfaces;
+using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -23,9 +27,12 @@ namespace Altinn.Studio.Designer.Controllers
public class ProcessModelingController : ControllerBase
{
private readonly IProcessModelingService _processModelingService;
- public ProcessModelingController(IProcessModelingService processModelingService)
+ private readonly IMediator _mediator;
+
+ public ProcessModelingController(IProcessModelingService processModelingService, IMediator mediator)
{
_processModelingService = processModelingService;
+ _mediator = mediator;
}
[HttpGet("process-definition")]
@@ -33,13 +40,17 @@ public FileStreamResult GetProcessDefinition(string org, string repo)
{
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);
- Stream processDefinitionStream = _processModelingService.GetProcessDefinitionStream(AltinnRepoEditingContext.FromOrgRepoDeveloper(org, repo, developer));
+ Stream processDefinitionStream =
+ _processModelingService.GetProcessDefinitionStream(
+ AltinnRepoEditingContext.FromOrgRepoDeveloper(org, repo, developer));
return new FileStreamResult(processDefinitionStream, MediaTypeNames.Text.Plain);
}
[HttpPut("process-definition")]
- public async Task SaveProcessDefinition(string org, string repo, CancellationToken cancellationToken)
+ [Obsolete("This endpoint should be replaced by process-definition-latest, and url fixed after integration with frontend")]
+ public async Task SaveProcessDefinition(string org, string repo,
+ CancellationToken cancellationToken)
{
Request.EnableBuffering();
try
@@ -52,7 +63,48 @@ public async Task SaveProcessDefinition(string org, string repo,
}
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);
- await _processModelingService.SaveProcessDefinitionAsync(AltinnRepoEditingContext.FromOrgRepoDeveloper(org, repo, developer), Request.Body, cancellationToken);
+ await _processModelingService.SaveProcessDefinitionAsync(
+ AltinnRepoEditingContext.FromOrgRepoDeveloper(org, repo, developer), Request.Body, cancellationToken);
+ return Ok();
+ }
+
+ [HttpPut("process-definition-latest")]
+ public async Task UpsertProcessDefinitionAndNotify(string org, string repo, [FromForm] IFormFile content, [FromForm] string metadata, CancellationToken cancellationToken)
+ {
+ Request.EnableBuffering();
+
+ var metadataObject = metadata is not null
+ ? JsonSerializer.Deserialize(metadata,
+ new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })
+ : null;
+
+ Stream stream = content.OpenReadStream();
+
+ try
+ {
+ await Guard.AssertValidXmlStreamAndRewindAsync(stream);
+ }
+ catch (ArgumentException)
+ {
+ return BadRequest("BPMN file is not valid XML");
+ }
+
+ string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);
+ await _processModelingService.SaveProcessDefinitionAsync(
+ AltinnRepoEditingContext.FromOrgRepoDeveloper(org, repo, developer), stream, cancellationToken);
+
+ if (metadataObject?.TaskIdChanges is not null)
+ {
+ foreach (TaskIdChange taskIdChange in metadataObject.TaskIdChanges)
+ {
+ await _mediator.Publish(new ProcessTaskIdChangedEvent
+ {
+ OldId = taskIdChange.OldId,
+ NewId = taskIdChange.NewId
+ }, cancellationToken);
+ }
+ }
+
return Ok();
}
@@ -64,37 +116,17 @@ public IEnumerable GetTemplates(string org, string repo, SemanticVersion
}
[HttpPut("templates/{appVersion}/{templateName}")]
- public async Task SaveProcessDefinitionFromTemplate(string org, string repo, SemanticVersion appVersion, string templateName, CancellationToken cancellationToken)
+ public async Task SaveProcessDefinitionFromTemplate(string org, string repo,
+ SemanticVersion appVersion, string templateName, CancellationToken cancellationToken)
{
Guard.AssertArgumentNotNull(appVersion, nameof(appVersion));
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);
var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, repo, developer);
- await _processModelingService.SaveProcessDefinitionFromTemplateAsync(editingContext, templateName, appVersion, cancellationToken);
+ await _processModelingService.SaveProcessDefinitionFromTemplateAsync(editingContext, templateName,
+ appVersion, cancellationToken);
Stream processDefinitionStream = _processModelingService.GetProcessDefinitionStream(editingContext);
return new FileStreamResult(processDefinitionStream, MediaTypeNames.Text.Plain);
}
-
- [HttpPut("tasks/{taskId}/{taskName}")]
- public async Task UpdateProcessTaskName(string org, string repo, string taskId, string taskName, CancellationToken cancellationToken)
- {
- Guard.AssertArgumentNotNull(taskId, nameof(taskId));
- Guard.AssertArgumentNotNull(taskName, nameof(taskName));
- string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);
- var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, repo, developer);
- try
- {
- Stream updatedProcessDefinitionStream = await _processModelingService.UpdateProcessTaskNameAsync(editingContext, taskId, taskName, cancellationToken);
- return new FileStreamResult(updatedProcessDefinitionStream, MediaTypeNames.Text.Plain);
- }
- catch (InvalidOperationException)
- {
- return BadRequest("Could not deserialize process definition.");
- }
- catch (ArgumentException)
- {
- return BadRequest("Could not find task with given id.");
- }
- }
}
}
diff --git a/backend/src/Designer/Designer.csproj b/backend/src/Designer/Designer.csproj
index 884fbe72545..65ddc481520 100644
--- a/backend/src/Designer/Designer.csproj
+++ b/backend/src/Designer/Designer.csproj
@@ -29,6 +29,7 @@
+
diff --git a/backend/src/Designer/EventHandlers/ProcessTaskIdChanged/ProcessTaskIdChangedApplicationMetadataHandler.cs b/backend/src/Designer/EventHandlers/ProcessTaskIdChanged/ProcessTaskIdChangedApplicationMetadataHandler.cs
new file mode 100644
index 00000000000..369231a9d58
--- /dev/null
+++ b/backend/src/Designer/EventHandlers/ProcessTaskIdChanged/ProcessTaskIdChangedApplicationMetadataHandler.cs
@@ -0,0 +1,16 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Altinn.Studio.Designer.Events;
+using MediatR;
+
+namespace Altinn.Studio.Designer.EventHandlers.ProcessTaskIdChanged;
+
+public class ProcessTaskIdChangedApplicationMetadataHandler : INotificationHandler
+{
+ public Task Handle(ProcessTaskIdChangedEvent notification, CancellationToken cancellationToken)
+ {
+ // TODO: Implement logic to handle the event here: https://github.com/Altinn/altinn-studio/issues/12220
+ // Here we should think how to handle errors in the handlers. Should we throw exceptions or use websocket to send error messages to the client?
+ return Task.CompletedTask;
+ }
+}
diff --git a/backend/src/Designer/Events/ProcessTaskIdChangedEvent.cs b/backend/src/Designer/Events/ProcessTaskIdChangedEvent.cs
new file mode 100644
index 00000000000..24434c39be2
--- /dev/null
+++ b/backend/src/Designer/Events/ProcessTaskIdChangedEvent.cs
@@ -0,0 +1,9 @@
+using MediatR;
+
+namespace Altinn.Studio.Designer.Events;
+
+public class ProcessTaskIdChangedEvent : INotification
+{
+ public string OldId { get; set; }
+ public string NewId { get; set; }
+}
diff --git a/backend/src/Designer/Models/Dto/ProcessDefinitionMetadata.cs b/backend/src/Designer/Models/Dto/ProcessDefinitionMetadata.cs
new file mode 100644
index 00000000000..1e91b1c4a01
--- /dev/null
+++ b/backend/src/Designer/Models/Dto/ProcessDefinitionMetadata.cs
@@ -0,0 +1,8 @@
+using System.Collections.Generic;
+
+namespace Altinn.Studio.Designer.Models.Dto;
+
+public class ProcessDefinitionMetadata
+{
+ public List TaskIdChanges { get; set; }
+}
diff --git a/backend/src/Designer/Models/Dto/TaskIdChange.cs b/backend/src/Designer/Models/Dto/TaskIdChange.cs
new file mode 100644
index 00000000000..daede977fc6
--- /dev/null
+++ b/backend/src/Designer/Models/Dto/TaskIdChange.cs
@@ -0,0 +1,7 @@
+namespace Altinn.Studio.Designer.Models.Dto;
+
+public class TaskIdChange
+{
+ public string OldId { get; set; }
+ public string NewId { get; set; }
+}
diff --git a/backend/src/Designer/ViewModels/Response/VersionResponse.cs b/backend/src/Designer/Models/Dto/VersionResponse.cs
similarity index 87%
rename from backend/src/Designer/ViewModels/Response/VersionResponse.cs
rename to backend/src/Designer/Models/Dto/VersionResponse.cs
index 4befb1b4ffc..d417038e063 100644
--- a/backend/src/Designer/ViewModels/Response/VersionResponse.cs
+++ b/backend/src/Designer/Models/Dto/VersionResponse.cs
@@ -1,6 +1,6 @@
using NuGet.Versioning;
-namespace Altinn.Studio.Designer.ViewModels.Response
+namespace Altinn.Studio.Designer.Models.Dto
{
public class VersionResponse
{
diff --git a/backend/src/Designer/Program.cs b/backend/src/Designer/Program.cs
index e3258c0a571..446b9e34d13 100644
--- a/backend/src/Designer/Program.cs
+++ b/backend/src/Designer/Program.cs
@@ -249,6 +249,10 @@ void ConfigureServices(IServiceCollection services, IConfiguration configuration
// Auto register all settings classes
services.RegisterSettingsByBaseType(configuration);
+
+ // Registers all handlers and the mediator
+ services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
+
logger.LogInformation("// Program.cs // ConfigureServices // Configuration complete");
}
diff --git a/backend/src/Designer/Services/Implementation/ProcessModeling/ProcessModelingService.cs b/backend/src/Designer/Services/Implementation/ProcessModeling/ProcessModelingService.cs
index 71dc3035a61..708ca63d278 100644
--- a/backend/src/Designer/Services/Implementation/ProcessModeling/ProcessModelingService.cs
+++ b/backend/src/Designer/Services/Implementation/ProcessModeling/ProcessModelingService.cs
@@ -56,37 +56,6 @@ public Stream GetProcessDefinitionStream(AltinnRepoEditingContext altinnRepoEdit
return altinnAppGitRepository.GetProcessDefinitionFile();
}
- public async Task UpdateProcessTaskNameAsync(AltinnRepoEditingContext altinnRepoEditingContext, string taskId, string taskName, CancellationToken cancellationToken = default)
- {
- cancellationToken.ThrowIfCancellationRequested();
- AltinnAppGitRepository altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(altinnRepoEditingContext.Org, altinnRepoEditingContext.Repo, altinnRepoEditingContext.Developer);
- XmlSerializer serializer = new(typeof(Definitions));
- Definitions? definitions;
- using (Stream processDefinitionStream = GetProcessDefinitionStream(altinnRepoEditingContext))
- {
- definitions = (Definitions?)serializer.Deserialize(processDefinitionStream);
- }
-
- if (definitions == null)
- {
- throw new InvalidOperationException("Could not deserialize process definition.");
- }
-
- ProcessTask? processTask = (definitions.Process.Tasks?.FirstOrDefault(t => t.Id == taskId)) ?? throw new ArgumentException($"Could not find task with id {taskId}.");
- processTask.Name = taskName;
-
- Stream processStream = new MemoryStream();
- serializer.Serialize(processStream, definitions);
-
- // Reset stream position to beginning after serialization
- processStream.Seek(0, SeekOrigin.Begin);
- await altinnAppGitRepository.SaveProcessDefinitionFileAsync(processStream, cancellationToken);
-
- // Reset stream position to beginning after saving
- processStream.Seek(0, SeekOrigin.Begin);
- return processStream;
- }
-
private IEnumerable EnumerateTemplateResources(SemanticVersion version)
{
return typeof(ProcessModelingService).Assembly.GetManifestResourceNames()
diff --git a/backend/src/Designer/Services/Interfaces/IProcessModelingService.cs b/backend/src/Designer/Services/Interfaces/IProcessModelingService.cs
index cc5e61db789..85d38e079cb 100644
--- a/backend/src/Designer/Services/Interfaces/IProcessModelingService.cs
+++ b/backend/src/Designer/Services/Interfaces/IProcessModelingService.cs
@@ -39,15 +39,5 @@ public interface IProcessModelingService
/// An .
/// A of a process definition file.
Stream GetProcessDefinitionStream(AltinnRepoEditingContext altinnRepoEditingContext);
-
- ///
- /// Updates the name of a task in the process definition file.
- ///
- /// n .
- /// The ID of the task to update
- /// The name to set for the task
- /// A that observes if operation is cancelled.
- ///
- Task UpdateProcessTaskNameAsync(AltinnRepoEditingContext altinnRepoEditingContext, string taskId, string taskName, CancellationToken cancellationToken = default);
}
}
diff --git a/backend/tests/Designer.Tests/Controllers/AppDevelopmentController/GetAppVersionTests.cs b/backend/tests/Designer.Tests/Controllers/AppDevelopmentController/GetAppVersionTests.cs
index 05cca89dd7f..fe0f13d1067 100644
--- a/backend/tests/Designer.Tests/Controllers/AppDevelopmentController/GetAppVersionTests.cs
+++ b/backend/tests/Designer.Tests/Controllers/AppDevelopmentController/GetAppVersionTests.cs
@@ -5,6 +5,7 @@
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
+using Altinn.Studio.Designer.Models.Dto;
using Altinn.Studio.Designer.ViewModels.Response;
using Designer.Tests.Controllers.ApiTests;
using Designer.Tests.Utils;
diff --git a/backend/tests/Designer.Tests/Controllers/ProcessModelingController/UpdateProcessTaskNameTests.cs b/backend/tests/Designer.Tests/Controllers/ProcessModelingController/UpdateProcessTaskNameTests.cs
deleted file mode 100644
index f2ae3fb0fa2..00000000000
--- a/backend/tests/Designer.Tests/Controllers/ProcessModelingController/UpdateProcessTaskNameTests.cs
+++ /dev/null
@@ -1,54 +0,0 @@
-using System.Net;
-using System.Net.Http;
-using System.Net.Mime;
-using System.Text;
-using System.Threading.Tasks;
-using System.Xml.Linq;
-using Designer.Tests.Controllers.ApiTests;
-using Designer.Tests.Utils;
-using FluentAssertions;
-using Microsoft.AspNetCore.Mvc.Testing;
-using Xunit;
-
-namespace Designer.Tests.Controllers.ProcessModelingController
-{
- public class UpdateProcessTaskName : DisagnerEndpointsTestsBase, IClassFixture>
- {
- private static string Url(string org, string repository, string taskId, string taskName) => $"/designer/api/{org}/{repository}/process-modelling/tasks/{taskId}/{taskName}";
-
- public UpdateProcessTaskName(WebApplicationFactory factory) : base(factory)
- {
- }
-
- [Theory]
- [InlineData("ttd", "app-with-process", "testUser", "Task_1", "NewTaskName")]
- public async Task UpdateProcessTaskName_ShouldReturnUpdatedProcess(string org, string app, string developer, string taskId, string taskName)
- {
- string targetRepository = TestDataHelper.GenerateTestRepoName();
- await CopyRepositoryForTest(org, app, developer, targetRepository);
-
- string url = Url(org, targetRepository, taskId, taskName);
-
- using var response = await HttpClient.PutAsync(url, null);
- response.StatusCode.Should().Be(HttpStatusCode.OK);
-
- string responseContent = await response.Content.ReadAsStringAsync();
-
- responseContent.Should().NotBeNullOrEmpty();
- responseContent.Should().Contain(taskName);
- }
-
- [Theory]
- [InlineData("ttd", "app-with-process", "testUser", "Does_not_exist", "NewTaskName")]
- public async Task InvalidTaskId_ShouldReturnBadRequest(string org, string app, string developer, string taskId, string taskName)
- {
- string targetRepository = TestDataHelper.GenerateTestRepoName();
- await CopyRepositoryForTest(org, app, developer, targetRepository);
-
- string url = Url(org, targetRepository, taskId, taskName);
-
- using var response = await HttpClient.PutAsync(url, null);
- response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
- }
- }
-}
diff --git a/backend/tests/Designer.Tests/Controllers/ProcessModelingController/UpsertProcessDefinitionAndNotifyTests.cs b/backend/tests/Designer.Tests/Controllers/ProcessModelingController/UpsertProcessDefinitionAndNotifyTests.cs
new file mode 100644
index 00000000000..8cc6d87dbad
--- /dev/null
+++ b/backend/tests/Designer.Tests/Controllers/ProcessModelingController/UpsertProcessDefinitionAndNotifyTests.cs
@@ -0,0 +1,65 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Net.Mime;
+using System.Text;
+using System.Text.Json;
+using System.Threading.Tasks;
+using System.Xml.Linq;
+using Altinn.Studio.Designer.Models.Dto;
+using Designer.Tests.Controllers.ApiTests;
+using Designer.Tests.Utils;
+using FluentAssertions;
+using Microsoft.AspNetCore.Mvc.Testing;
+using SharedResources.Tests;
+using Xunit;
+
+namespace Designer.Tests.Controllers.ProcessModelingController;
+
+public class UpsertProcessDefinitionAndNotifyTests : DisagnerEndpointsTestsBase, IClassFixture>
+{
+ private static string VersionPrefix(string org, string repository) => $"/designer/api/{org}/{repository}/process-modelling/process-definition-latest";
+
+ public UpsertProcessDefinitionAndNotifyTests(WebApplicationFactory factory) : base(factory)
+ {
+ }
+
+ [Theory]
+ [MemberData(nameof(UpsertProcessDefinitionAndNotifyTestData))]
+ public async Task UpsertProcessDefinition_ShouldReturnOk(string org, string app, string developer, string bpmnFilePath, ProcessDefinitionMetadata metadata)
+ {
+ string targetRepository = TestDataHelper.GenerateTestRepoName();
+ await CopyRepositoryForTest(org, app, developer, targetRepository);
+ string fileContent = SharedResourcesHelper.LoadTestDataAsString(bpmnFilePath);
+ fileContent = metadata.TaskIdChanges.Aggregate(fileContent, (current, metadataTaskIdChange) => current.Replace(metadataTaskIdChange.OldId, metadataTaskIdChange.NewId));
+ using var fileStream = new MemoryStream(Encoding.UTF8.GetBytes(fileContent));
+
+ string url = VersionPrefix(org, targetRepository);
+
+ using var form = new MultipartFormDataContent();
+ string metadataString = JsonSerializer.Serialize(metadata,
+ new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
+ form.Add(new StreamContent(fileStream), "content", "process.bpmn");
+ form.Add(new StringContent(metadataString, Encoding.UTF8, MediaTypeNames.Application.Json), "metadata");
+
+ using var response = await HttpClient.PutAsync(url, form);
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ string savedFile = TestDataHelper.GetFileFromRepo(org, targetRepository, developer, "App/config/process/process.bpmn");
+
+ XDocument expectedXml = XDocument.Parse(fileContent);
+ XDocument savedXml = XDocument.Parse(savedFile);
+ XNode.DeepEquals(savedXml, expectedXml).Should().BeTrue();
+ }
+
+ public static IEnumerable