Skip to content

Commit 69e76be

Browse files
authored
Feat/organization migration (#14)
* feat: add organization-management component & UserProfileConfigServiceImpl * fix: fix tests * feat: add sanitizeSubdomainInput * fix: remove console.log & add tests
1 parent c29dc1c commit 69e76be

18 files changed

+632
-13
lines changed

jest.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,7 @@ module.exports = {
44
collectCoverage: true,
55
modulePathIgnorePatterns: ['<rootDir>/dist/'],
66
coveragePathIgnorePatterns: ['/node_modules/', '/integration-tests/'],
7+
moduleNameMapper: {
8+
'^@luigi-project/client-support-angular$': '<rootDir>/projects/lib/_mocks_/luigi-client-support-angular.ts',
9+
},
710
};

package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
"@angular-builders/jest": "^20.0.0",
1717
"@angular-devkit/build-angular": "^20.0.0",
1818
"@angular-eslint/builder": "^20.2.0",
19-
"@angular/cli": "^20.2.0",
2019
"@angular/build": "^20.2.1",
20+
"@angular/cli": "^20.2.0",
2121
"@angular/compiler-cli": "^20.2.1",
2222
"@angular/localize": "^20.2.1",
2323
"@briebug/jest-schematic": "^6.0.0",
@@ -30,11 +30,11 @@
3030
"jest-junit": "16.0.0",
3131
"jest-mock-extended": "3.0.7",
3232
"jmespath": "0.16.0",
33+
"mkdirp": "^3.0.1",
3334
"ng-packagr": "^20.2.0",
34-
"ts-jest": "29.3.2",
35-
"typescript": "~5.8.0",
35+
"nodemon": "3.1.10",
3636
"rimraf": "6.0.1",
37-
"mkdirp": "^3.0.1",
38-
"nodemon": "3.1.10"
37+
"ts-jest": "29.3.2",
38+
"typescript": "~5.8.0"
3939
}
4040
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { Observable, of } from 'rxjs';
2+
export class LuigiContextService {
3+
contextObservable(): Observable<{ context: any }> {
4+
return of({ context: null });
5+
}
6+
}

projects/lib/_mocks_/ui5‑mock.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Component } from '@angular/core';
2+
import { ButtonComponent } from '@ui5/webcomponents-ngx';
3+
4+
@Component({ selector: 'ui5-component', template: '', standalone: true })
5+
export class MockComponent {}
6+
7+
jest.mock('@ui5/webcomponents-ngx', () => {
8+
return {
9+
BreadcrumbsComponent: MockComponent,
10+
BreadcrumbsItemComponent: MockComponent,
11+
ButtonComponent: MockComponent,
12+
DialogComponent: MockComponent,
13+
DynamicPageComponent: MockComponent,
14+
DynamicPageHeaderComponent: MockComponent,
15+
DynamicPageTitleComponent: MockComponent,
16+
IconComponent: MockComponent,
17+
IllustratedMessageComponent: MockComponent,
18+
InputComponent: MockComponent,
19+
LabelComponent: MockComponent,
20+
OptionComponent: MockComponent,
21+
SelectComponent: MockComponent,
22+
TableCellComponent: MockComponent,
23+
TableComponent: MockComponent,
24+
TableHeaderCellComponent: MockComponent,
25+
TableHeaderRowComponent: MockComponent,
26+
TableRowComponent: MockComponent,
27+
TextComponent: MockComponent,
28+
TitleComponent: MockComponent,
29+
ToolbarButtonComponent: MockComponent,
30+
ToolbarComponent: MockComponent,
31+
};
32+
});

projects/lib/jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const path = require('path');
33
module.exports = {
44
displayName: 'lib',
55
coverageDirectory: path.resolve(__dirname, '../../coverage/lib'),
6+
setupFilesAfterEnv: [`${__dirname}/jest.setup.ts`],
67
coverageThreshold: {
78
global: {
89
branches: 67,

projects/lib/jest.setup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
jest.requireMock('./_mocks_/ui5‑mock');
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<div class="organization-management">
2+
<div>
3+
{{ texts.explanation }}
4+
</div>
5+
6+
<div>
7+
<ui5-label for="select-switch" show-colon>{{
8+
texts.switchOrganization.label
9+
}}</ui5-label
10+
><br />
11+
<div class="organization-management-input">
12+
<ui5-select
13+
id="select-switch"
14+
[value]="organizationToSwitch"
15+
(input)="setOrganizationToSwitch($event)"
16+
(change)="setOrganizationToSwitch($event)"
17+
>
18+
@for (org of organizations(); track org) {
19+
<ui5-option [value]="org" [selected]="org === organizationToSwitch">{{
20+
org
21+
}}</ui5-option>
22+
}
23+
</ui5-select>
24+
<ui5-button design="Emphasized" (ui5Click)="switchOrganization()">{{
25+
texts.switchOrganization.button
26+
}}</ui5-button>
27+
</div>
28+
</div>
29+
30+
<div>
31+
<ui5-label for="input-onboard" show-colon>{{
32+
texts.onboardOrganization.label
33+
}}</ui5-label
34+
><br />
35+
<div class="organization-management-input">
36+
<ui5-input
37+
id="input-onboard"
38+
placeholder="{{ texts.onboardOrganization.placeholder }}"
39+
[(ngModel)]="newOrganization"
40+
></ui5-input>
41+
<ui5-button design="Emphasized" (ui5Click)="onboardOrganization()">{{
42+
texts.onboardOrganization.button
43+
}}</ui5-button>
44+
</div>
45+
</div>
46+
</div>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
.organization-management {
2+
margin: 1.5rem;
3+
display: flex;
4+
flex-direction: column;
5+
gap: 1rem;
6+
}
7+
8+
.organization-management-input {
9+
display: flex;
10+
align-items: center;
11+
gap: 1rem;
12+
}
13+
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import {
2+
CUSTOM_ELEMENTS_SCHEMA,
3+
NO_ERRORS_SCHEMA,
4+
signal
5+
} from '@angular/core';
6+
import { ComponentFixture, TestBed } from '@angular/core/testing';
7+
import { FormsModule } from '@angular/forms';
8+
import { MutationResult } from '@apollo/client';
9+
import { LuigiContextService } from '@luigi-project/client-support-angular';
10+
import {
11+
ClientEnvironment, EnvConfigService,
12+
I18nService,
13+
LuigiCoreService, LuigiGlobalContext, NodeContext, ResourceService
14+
} from '@openmfp/portal-ui-lib';
15+
import { of, throwError } from 'rxjs';
16+
import { OrganizationManagementComponent } from './organization-management.component';
17+
18+
describe('OrganizationManagementComponent', () => {
19+
let component: OrganizationManagementComponent;
20+
let fixture: ComponentFixture<OrganizationManagementComponent>;
21+
let resourceServiceMock: jest.Mocked<ResourceService>;
22+
let i18nServiceMock: jest.Mocked<I18nService>;
23+
let luigiCoreServiceMock: jest.Mocked<LuigiCoreService>;
24+
let envConfigServiceMock: jest.Mocked<EnvConfigService>;
25+
26+
beforeEach(async () => {
27+
resourceServiceMock = {
28+
readOrganizations: jest.fn(),
29+
create: jest.fn(),
30+
} as any;
31+
32+
i18nServiceMock = {
33+
translationTable: {},
34+
getTranslation: jest.fn(),
35+
} as any;
36+
37+
luigiCoreServiceMock = {
38+
getGlobalContext: jest.fn(),
39+
showAlert: jest.fn(),
40+
} as any;
41+
42+
envConfigServiceMock = {
43+
getEnvConfig: jest.fn(),
44+
} as any;
45+
46+
await TestBed.configureTestingModule({
47+
imports: [OrganizationManagementComponent, FormsModule],
48+
providers: [
49+
{ provide: ResourceService, useValue: resourceServiceMock },
50+
{ provide: I18nService, useValue: i18nServiceMock },
51+
{ provide: LuigiCoreService, useValue: luigiCoreServiceMock },
52+
{ provide: EnvConfigService, useValue: envConfigServiceMock },
53+
LuigiContextService,
54+
],
55+
schemas: [CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA],
56+
})
57+
.overrideComponent(OrganizationManagementComponent, {
58+
set: { template: '', imports: [] },
59+
})
60+
.compileComponents();
61+
62+
fixture = TestBed.createComponent(OrganizationManagementComponent);
63+
component = fixture.componentInstance;
64+
});
65+
66+
it('should create', () => {
67+
expect(component).toBeTruthy();
68+
});
69+
70+
it('should react to context input change', () => {
71+
const mockContext = {
72+
translationTable: { hello: 'world' },
73+
} as any as NodeContext;
74+
75+
resourceServiceMock.readOrganizations.mockReturnValue(of({} as any));
76+
77+
const contextSignal = signal<NodeContext | null>(mockContext);
78+
component.context = contextSignal as any;
79+
80+
fixture.detectChanges();
81+
82+
expect(component['i18nService'].translationTable).toEqual(
83+
mockContext.translationTable,
84+
);
85+
});
86+
87+
it('should initialize with empty organizations', () => {
88+
expect(component.organizations()).toEqual([]);
89+
});
90+
91+
it('should read organizations on init', () => {
92+
const mockOrganizations = {
93+
Accounts: [
94+
{ metadata: { name: 'org1' } },
95+
{ metadata: { name: 'org2' } },
96+
],
97+
};
98+
const mockGlobalContext: LuigiGlobalContext = {
99+
portalContext: {},
100+
userId: 'user1',
101+
userEmail: '[email protected]',
102+
token: 'token',
103+
organization: 'org1',
104+
portalBaseUrl: 'https://test.com',
105+
};
106+
luigiCoreServiceMock.getGlobalContext.mockReturnValue(mockGlobalContext);
107+
resourceServiceMock.readOrganizations.mockReturnValue(
108+
of(mockOrganizations as any),
109+
);
110+
111+
component.ngOnInit();
112+
113+
expect(resourceServiceMock.readOrganizations).toHaveBeenCalled();
114+
expect(component.organizations()).toEqual(['org2']);
115+
});
116+
117+
it('should set organization to switch', () => {
118+
const event = { target: { value: 'testOrg' } };
119+
component.setOrganizationToSwitch(event);
120+
expect(component.organizationToSwitch).toBe('testOrg');
121+
});
122+
123+
it('should onboard new organization successfully', () => {
124+
const mockResponse: MutationResult<void> = {
125+
data: undefined,
126+
loading: false,
127+
error: undefined,
128+
called: true,
129+
client: {} as any,
130+
reset: jest.fn(),
131+
};
132+
resourceServiceMock.create.mockReturnValue(of(mockResponse));
133+
component.newOrganization = 'newOrg';
134+
component.organizations.set(['existingOrg']);
135+
136+
component.onboardOrganization();
137+
138+
expect(resourceServiceMock.create).toHaveBeenCalled();
139+
expect(component.organizations()).toEqual(['newOrg', 'existingOrg']);
140+
expect(component.organizationToSwitch).toBe('newOrg');
141+
expect(component.newOrganization).toBe('');
142+
});
143+
144+
it('should handle organization creation error', () => {
145+
resourceServiceMock.create.mockReturnValue(
146+
throwError(() => new Error('Creation failed')),
147+
);
148+
component.newOrganization = 'newOrg';
149+
150+
component.onboardOrganization();
151+
152+
expect(luigiCoreServiceMock.showAlert).toHaveBeenCalledWith({
153+
text: 'Failure! Could not create organization: newOrg.',
154+
type: 'error',
155+
});
156+
});
157+
158+
it('should switch organization', async () => {
159+
const mockEnvConfig: ClientEnvironment = {
160+
idpName: 'test',
161+
organization: 'test',
162+
oauthServerUrl: 'https://test.com',
163+
clientId: 'test',
164+
baseDomain: 'test.com',
165+
isLocal: false,
166+
developmentInstance: false,
167+
authData: {
168+
expires_in: '3600',
169+
access_token: 'test-access-token',
170+
id_token: 'test-id-token',
171+
},
172+
};
173+
envConfigServiceMock.getEnvConfig.mockResolvedValue(mockEnvConfig);
174+
component.organizationToSwitch = 'newOrg';
175+
Object.defineProperty(window, 'location', {
176+
value: { protocol: 'https:', port: '8080' },
177+
writable: true,
178+
});
179+
180+
await component.switchOrganization();
181+
182+
expect(window.location.href).toBe('https://newOrg.test.com:8080');
183+
});
184+
185+
it('should not switch and show alert for invalid organization name', async () => {
186+
const mockEnvConfig: ClientEnvironment = {
187+
idpName: 'test',
188+
organization: 'test',
189+
oauthServerUrl: 'https://test.com',
190+
clientId: 'test',
191+
baseDomain: 'test.com',
192+
isLocal: false,
193+
developmentInstance: false,
194+
authData: {
195+
expires_in: '3600',
196+
access_token: 'test-access-token',
197+
id_token: 'test-id-token',
198+
},
199+
};
200+
envConfigServiceMock.getEnvConfig.mockResolvedValue(mockEnvConfig);
201+
202+
const invalidNames = ['-abc', 'abc-', 'a.b', 'a b', ''];
203+
204+
for (const name of invalidNames) {
205+
component.organizationToSwitch = name as any;
206+
Object.defineProperty(window, 'location', {
207+
value: { protocol: 'https:', port: '' },
208+
writable: true,
209+
});
210+
211+
await component.switchOrganization();
212+
213+
expect(luigiCoreServiceMock.showAlert).toHaveBeenCalledWith({
214+
text:
215+
'Organization name is not valid for subdomain usage, accrording to RFC 1034/1123.',
216+
type: 'error',
217+
});
218+
219+
expect((window.location as any).href).toBeUndefined();
220+
(luigiCoreServiceMock.showAlert as jest.Mock).mockClear();
221+
}
222+
});
223+
});

0 commit comments

Comments
 (0)