dev режим убран
This commit is contained in:
@@ -4,9 +4,6 @@ namespace Tnsu.ApprovalRoute.Api.Auth;
|
||||
|
||||
public static class AuthSchemes
|
||||
{
|
||||
/// <summary>Локальная разработка и тесты — Entra не настроен (см. AuthSetup).</summary>
|
||||
public const string Dev = "Dev";
|
||||
|
||||
/// <summary>Токен пользователя (делегированный) — обычная работа админки.</summary>
|
||||
public const string Bearer = JwtBearerDefaults.AuthenticationScheme;
|
||||
|
||||
|
||||
@@ -20,11 +20,11 @@ public static class AuthSetup
|
||||
var tenantId = configuration["Entra:TenantId"];
|
||||
var audience = configuration["Entra:Audience"];
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId) && !string.IsNullOrWhiteSpace(audience))
|
||||
AddEntraAuth(services, configuration, tenantId, audience);
|
||||
else
|
||||
AddDevAuth(services);
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(audience))
|
||||
throw new InvalidOperationException(
|
||||
"Entra:TenantId и Entra:Audience обязательны — локального Dev-режима без них больше нет.");
|
||||
|
||||
AddEntraAuth(services, configuration, tenantId, audience);
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -125,26 +125,4 @@ public static class AuthSetup
|
||||
options.DefaultPolicy = options.GetPolicy(AuthPolicies.Any)!;
|
||||
});
|
||||
}
|
||||
|
||||
private static void AddDevAuth(IServiceCollection services)
|
||||
{
|
||||
services.AddAuthentication(AuthSchemes.Dev)
|
||||
.AddScheme<AuthenticationSchemeOptions, DevAuthHandler>(AuthSchemes.Dev, _ => { });
|
||||
|
||||
// Те же имена политик, но поверх Dev-схемы: иначе контроллер с
|
||||
// [Authorize(Policy = ...)] падал бы локально с «политика не найдена».
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
foreach (var name in new[] { AuthPolicies.Delegated, AuthPolicies.AppOnly, AuthPolicies.Any })
|
||||
{
|
||||
options.AddPolicy(name, policy =>
|
||||
{
|
||||
policy.AuthenticationSchemes.Add(AuthSchemes.Dev);
|
||||
policy.RequireAuthenticatedUser();
|
||||
});
|
||||
}
|
||||
|
||||
options.DefaultPolicy = options.GetPolicy(AuthPolicies.Any)!;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Api.Auth;
|
||||
|
||||
public sealed class DevAuthHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
IConfiguration configuration)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var email = Request.Headers["X-Dev-User-Email"].FirstOrDefault()
|
||||
?? configuration["Dev:DefaultUserEmail"]
|
||||
?? "dev@tnsu.kz";
|
||||
|
||||
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Email, email)], AuthSchemes.Dev);
|
||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), AuthSchemes.Dev);
|
||||
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
{
|
||||
"Entra": {
|
||||
"TenantId": "",
|
||||
"Audience": ""
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"Postgres": "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route"
|
||||
}
|
||||
|
||||
@@ -13,9 +13,6 @@
|
||||
"Audience": "9232d9e2-9807-4753-808f-07250e5474b0",
|
||||
"AllowedAppIds": []
|
||||
},
|
||||
"Dev": {
|
||||
"DefaultUserEmail": "dev@tnsu.kz"
|
||||
},
|
||||
"Cors": {
|
||||
"AllowedOrigins": [
|
||||
"https://tnsukz.sharepoint.com",
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import * as ReactDom from 'react-dom';
|
||||
import { Version } from '@microsoft/sp-core-library';
|
||||
import {
|
||||
type IPropertyPaneConfiguration,
|
||||
PropertyPaneTextField,
|
||||
PropertyPaneToggle
|
||||
} from '@microsoft/sp-property-pane';
|
||||
import { type IPropertyPaneConfiguration, PropertyPaneTextField } from '@microsoft/sp-property-pane';
|
||||
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
|
||||
|
||||
import ApprovalAdmin from './components/ApprovalAdmin';
|
||||
@@ -13,8 +9,6 @@ import { IApprovalAdminProps } from './components/IApprovalAdminProps';
|
||||
|
||||
export interface IApprovalAdminWebPartProps {
|
||||
apiBaseUrl: string;
|
||||
useDevAuth: boolean;
|
||||
devUserEmail: string;
|
||||
aadResourceId: string;
|
||||
portalBaseUrl: string;
|
||||
}
|
||||
@@ -25,8 +19,6 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprova
|
||||
const element: React.ReactElement<IApprovalAdminProps> = React.createElement(ApprovalAdmin, {
|
||||
context: this.context,
|
||||
apiBaseUrl: this.properties.apiBaseUrl,
|
||||
useDevAuth: this.properties.useDevAuth,
|
||||
devUserEmail: this.properties.devUserEmail,
|
||||
aadResourceId: this.properties.aadResourceId,
|
||||
portalBaseUrl: this.properties.portalBaseUrl
|
||||
});
|
||||
@@ -39,11 +31,6 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprova
|
||||
// Для локальной разработки переопределите значение в панели свойств веб-части
|
||||
// (например, на http://localhost:5080 — порт из backend/.../launchSettings.json).
|
||||
this.properties.apiBaseUrl = this.properties.apiBaseUrl || 'https://api.tnsu.kz/approval-route';
|
||||
// Dev-режим ломает /resolve: наш бэкенд не получает Bearer-токен пользователя
|
||||
// для пересылки в api.tnsu.kz и падает на собственном client_credentials
|
||||
// (TnsuPortal:ClientSecret в appsettings.json пуст). По умолчанию — реальный AAD.
|
||||
this.properties.useDevAuth = this.properties.useDevAuth ?? false;
|
||||
this.properties.devUserEmail = this.properties.devUserEmail || 'dev@tnsu.kz';
|
||||
this.properties.aadResourceId = this.properties.aadResourceId || 'api://9232d9e2-9807-4753-808f-07250e5474b0';
|
||||
this.properties.portalBaseUrl = this.properties.portalBaseUrl || 'https://api.tnsu.kz';
|
||||
return Promise.resolve();
|
||||
@@ -72,15 +59,6 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprova
|
||||
label: 'Api Base Url',
|
||||
description: 'По умолчанию — прод (https://api.tnsu.kz/approval-route). Для локальной разработки укажите http://localhost:5080.'
|
||||
}),
|
||||
PropertyPaneToggle('useDevAuth', {
|
||||
label: 'Локальный Dev-режим (без Azure AD)',
|
||||
onText: 'Вкл. (заголовок X-Dev-User-Email)',
|
||||
offText: 'Выкл. (AAD-токен через SPFx)'
|
||||
}),
|
||||
PropertyPaneTextField('devUserEmail', {
|
||||
label: 'Dev: email пользователя',
|
||||
description: 'Используется только при включённом Dev-режиме.'
|
||||
}),
|
||||
PropertyPaneTextField('aadResourceId', {
|
||||
label: 'AAD Resource Id (App ID URI)',
|
||||
description: 'Должен совпадать с webApiPermissionRequests в package-solution.json. Этим же токеном берутся справочники api.tnsu.kz.'
|
||||
|
||||
@@ -3,7 +3,7 @@ import styles from './ApprovalAdmin.module.scss';
|
||||
import type { IApprovalAdminProps } from './IApprovalAdminProps';
|
||||
import { AdminApp } from './app/AdminApp';
|
||||
import { ApprovalRouteApi } from '../services/api';
|
||||
import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient';
|
||||
import { AadApiHttpClient, IHttpClient } from '../services/httpClient';
|
||||
import { PortalDictionaryClient } from '../services/portalDictionary';
|
||||
import { SharePointListsClient } from '../services/sharePointLists';
|
||||
import { RequestManageService } from '../services/requestManageService';
|
||||
@@ -21,9 +21,6 @@ function ensureFontsLoaded(): void {
|
||||
}
|
||||
|
||||
async function createHttpClient(props: IApprovalAdminProps): Promise<IHttpClient> {
|
||||
if (props.useDevAuth) {
|
||||
return new DevApiHttpClient(props.apiBaseUrl, props.devUserEmail || 'dev@tnsu.kz');
|
||||
}
|
||||
const aadClient = await props.context.aadHttpClientFactory.getClient(props.aadResourceId);
|
||||
return new AadApiHttpClient(aadClient, props.apiBaseUrl);
|
||||
}
|
||||
@@ -50,11 +47,8 @@ const ApprovalAdmin: React.FC<IApprovalAdminProps> = props => {
|
||||
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.useDevAuth, props.apiBaseUrl, props.devUserEmail, props.aadResourceId]);
|
||||
}, [props.apiBaseUrl, props.aadResourceId]);
|
||||
|
||||
// Справочники всегда идут через AAD, независимо от Dev-режима нашего API:
|
||||
// api.tnsu.kz принимает только настоящий токен, заголовок X-Dev-User-Email
|
||||
// ему ничего не говорит.
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
setDictionary(undefined);
|
||||
|
||||
@@ -3,8 +3,6 @@ import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
export interface IApprovalAdminProps {
|
||||
context: WebPartContext;
|
||||
apiBaseUrl: string;
|
||||
useDevAuth: boolean;
|
||||
devUserEmail: string;
|
||||
aadResourceId: string;
|
||||
portalBaseUrl: string;
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
// бэкенд ищет по компании инициатора). Найденные в других компаниях
|
||||
// этому маршруту не подойдут — их надо не прятать, а объяснить, что они не в счёт.
|
||||
const inRouteCompany = routeCompanyName
|
||||
? positionMatches.exact.filter(e => e.company === routeCompanyName)
|
||||
? positionMatches.exact.filter(e => e.companies.indexOf(routeCompanyName) !== -1)
|
||||
: positionMatches.exact;
|
||||
|
||||
if (inRouteCompany.length > 0) {
|
||||
@@ -235,12 +235,18 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
: texts.routeSteps.positionPreviewFound(inRouteCompany.length)}
|
||||
</span>
|
||||
<ul className={styles.previewList}>
|
||||
{inRouteCompany.map(e => (
|
||||
{inRouteCompany.map(e => {
|
||||
// Показываем ИМЕННО ту компанию, по которой сотрудник совпал с
|
||||
// маршрутом — она не обязана быть его company (первой из
|
||||
// справочника), если он числится в нескольких (см. companies).
|
||||
const shownCompany = routeCompanyName ?? e.company;
|
||||
return (
|
||||
<li key={e.email}>
|
||||
<span className={styles.previewPerson}>{e.fio}</span>
|
||||
{e.company && <span className={styles.previewCompany}> · {e.company}</span>}
|
||||
{shownCompany && <span className={styles.previewCompany}> · {shownCompany}</span>}
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -34,10 +34,17 @@ export interface Employee {
|
||||
* отображения в списке (сам справочник должностей не показывает). */
|
||||
position: string;
|
||||
/**
|
||||
* Компания сотрудника — '' если не пришла из справочника. Нужна подсказке
|
||||
* «кого найдёт эта должность» в форме шага: бэкенд ищет исполнителя по ПАРЕ
|
||||
* (должность, компания инициатора), поэтому одну и ту же должность в разных
|
||||
* юрлицах занимают разные люди, и без компании подсказка была бы неоднозначной.
|
||||
* Первая компания сотрудника из справочника ('' если не пришла) — для
|
||||
* отображения в пикере/списках, где на человека нужна ровно одна строка.
|
||||
*/
|
||||
company: string;
|
||||
/**
|
||||
* ВСЕ компании, где числится сотрудник — совместители и штат общих служб
|
||||
* (бухгалтерия, HR) на несколько юрлиц не редкость: в api.tnsu.kz это
|
||||
* отдельная строка на каждую компанию, а не одна с списком. Проверять
|
||||
* принадлежность к конкретной компании (маршрут ограничен ею) нужно по
|
||||
* этому полю, а не по company — иначе сотрудник, у которого в справочнике
|
||||
* первой оказалась ДРУГАЯ его компания, ошибочно выглядел бы отсутствующим.
|
||||
*/
|
||||
companies: string[];
|
||||
}
|
||||
|
||||
@@ -116,45 +116,3 @@ export class AadApiHttpClient implements IHttpClient {
|
||||
.then((r: HttpClientResponse) => parseResponse<T>(r));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Локальная отладка: обычный fetch с заголовком X-Dev-User-Email.
|
||||
* Работает только если бэкенд поднят с пустыми Entra:TenantId/Entra:Audience
|
||||
* (см. backend/.../Auth/AuthSetup.cs — тогда включается DevAuthHandler).
|
||||
*/
|
||||
export class DevApiHttpClient implements IHttpClient {
|
||||
constructor(private readonly baseUrl: string, private readonly devUserEmail: string) {}
|
||||
|
||||
public get<T>(path: string, query?: QueryParams): Promise<T> {
|
||||
return this.send<T>('GET', path, undefined, query);
|
||||
}
|
||||
|
||||
public post<T>(path: string, body?: unknown): Promise<T> {
|
||||
return this.send<T>('POST', path, body);
|
||||
}
|
||||
|
||||
public put<T>(path: string, body?: unknown): Promise<T> {
|
||||
return this.send<T>('PUT', path, body);
|
||||
}
|
||||
|
||||
public patch<T>(path: string, body?: unknown): Promise<T> {
|
||||
return this.send<T>('PATCH', path, body);
|
||||
}
|
||||
|
||||
public async delete(path: string): Promise<void> {
|
||||
await this.send<void>('DELETE', path);
|
||||
}
|
||||
|
||||
private async send<T>(method: string, path: string, body?: unknown, query?: QueryParams): Promise<T> {
|
||||
const url = buildUrl(this.baseUrl, path, query);
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Dev-User-Email': this.devUserEmail
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined
|
||||
});
|
||||
return parseResponse<T>(response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { AadHttpClient } from '@microsoft/sp-http';
|
||||
import { PortalDictionaryClient } from './portalDictionary';
|
||||
|
||||
// Мок-клиент вместо настоящего AadHttpClient — employees() дёргает только .get().
|
||||
function clientReturning(body: unknown): AadHttpClient {
|
||||
return {
|
||||
get: () =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(JSON.stringify(body))
|
||||
})
|
||||
} as unknown as AadHttpClient;
|
||||
}
|
||||
|
||||
describe('PortalDictionaryClient.employees', () => {
|
||||
// Регрессия: api.tnsu.kz отдаёт ОТДЕЛЬНУЮ строку на каждую компанию, где
|
||||
// числится сотрудник (совместители/бухгалтерия на несколько юрлиц — не
|
||||
// редкость). Раньше company у дедуплицированной записи оставалась только
|
||||
// первой попавшейся, а остальные компании терялись — сотрудник ошибочно
|
||||
// выглядел отсутствующим в них (см. forms/Clearance-подсказку в RouteStepFormModal).
|
||||
it('строки одного email по разным компаниям схлопываются в одного сотрудника, но все компании сохраняются', async () => {
|
||||
const client = clientReturning([
|
||||
{ fio: 'Тәжіғалиев Биғали', email: 'b@ct-global.kz', position: 'Главный бухгалтер', company: 'ТОО ГеоТрек' },
|
||||
{ fio: 'Тәжіғалиев Биғали', email: 'b@ct-global.kz', position: 'Главный бухгалтер', company: 'ТОО AFL Engineering' },
|
||||
{ fio: 'Тәжіғалиев Биғали', email: 'b@ct-global.kz', position: 'Главный бухгалтер', company: 'ТОО СТ Global' }
|
||||
]);
|
||||
|
||||
const employees = await new PortalDictionaryClient(client, 'https://api.tnsu.kz').employees();
|
||||
|
||||
expect(employees).toHaveLength(1);
|
||||
expect(employees[0].company).toBe('ТОО ГеоТрек'); // первая — для пикера/отображения
|
||||
expect(employees[0].companies).toEqual(['ТОО ГеоТрек', 'ТОО AFL Engineering', 'ТОО СТ Global']);
|
||||
});
|
||||
|
||||
it('разные email — разные сотрудники, дедупликация их не задевает', async () => {
|
||||
const client = clientReturning([
|
||||
{ fio: 'A', email: 'a@tnsu.kz', position: 'Инженер', company: 'Компания А' },
|
||||
{ fio: 'B', email: 'b@tnsu.kz', position: 'Инженер', company: 'Компания Б' }
|
||||
]);
|
||||
|
||||
const employees = await new PortalDictionaryClient(client, 'https://api.tnsu.kz').employees();
|
||||
|
||||
expect(employees.map(e => e.email)).toEqual(['a@tnsu.kz', 'b@tnsu.kz']);
|
||||
expect(employees.map(e => e.companies)).toEqual([['Компания А'], ['Компания Б']]);
|
||||
});
|
||||
|
||||
it('сотрудник без email в строке — пропускается (по нему нельзя резолвить исполнителя)', async () => {
|
||||
const client = clientReturning([
|
||||
{ fio: 'Без Email', email: null, position: 'Инженер', company: 'Компания А' }
|
||||
]);
|
||||
|
||||
const employees = await new PortalDictionaryClient(client, 'https://api.tnsu.kz').employees();
|
||||
|
||||
expect(employees).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -117,13 +117,17 @@ export class PortalDictionaryClient {
|
||||
fio: x.fio,
|
||||
email: x.email,
|
||||
position: x.position || '',
|
||||
company: x.company || ''
|
||||
company: x.company || '',
|
||||
// Одна строка на каждую компанию, где числится сотрудник (совместители/
|
||||
// бухгалтерия на несколько юрлиц — не редкость), companies собирает их
|
||||
// все, а company остаётся первой встреченной (для пикера/отображения,
|
||||
// где нужна ровно одна строка на человека).
|
||||
companies: x.company ? [x.company] : []
|
||||
});
|
||||
} else {
|
||||
// Один сотрудник — строка на каждый его отдел; должность и компания
|
||||
// могли найтись не в первой из них.
|
||||
if (!existing.position && x.position) existing.position = x.position;
|
||||
if (!existing.company && x.company) existing.company = x.company;
|
||||
if (x.company && existing.companies.indexOf(x.company) === -1) existing.companies.push(x.company);
|
||||
}
|
||||
});
|
||||
return Array.from(byEmail.values());
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { Employee } from '../models';
|
||||
import { findEmployeeByEmail, matchEmployeesByPosition, toEmployeeOption } from './employee';
|
||||
|
||||
// Хелпер вместо повторения company/companies в каждом литерале — в тестах
|
||||
// нет нужды в разных компаниях у одного сотрудника (это отдельно проверяется
|
||||
// в matchEmployeesByPosition ниже).
|
||||
function emp(fio: string, email: string, position: string, company = ''): Employee {
|
||||
return { fio, email, position, company, companies: company ? [company] : [] };
|
||||
}
|
||||
|
||||
const employees: Employee[] = [
|
||||
{ fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик middle', company: 'ТОО TANSU Construction' },
|
||||
{ fio: 'Соколов Давид', email: 'sokolov.david@tnsu.kz', position: 'Руководитель отдела', company: 'ТОО TANSU Construction' }
|
||||
emp('Горбенко Павел', 'gorbenko.pavel@tnsu.kz', 'Разработчик middle', 'ТОО TANSU Construction'),
|
||||
emp('Соколов Давид', 'sokolov.david@tnsu.kz', 'Руководитель отдела', 'ТОО TANSU Construction')
|
||||
];
|
||||
|
||||
describe('findEmployeeByEmail', () => {
|
||||
@@ -37,7 +44,7 @@ describe('toEmployeeOption', () => {
|
||||
// Должность в справочнике заполнена не у всех — тогда подпись не должна
|
||||
// начинаться с висящего разделителя.
|
||||
it('без должности в подписи остаётся только email', () => {
|
||||
expect(toEmployeeOption({ fio: 'Без Должности', email: 'nobody@tnsu.kz', position: '', company: '' })).toEqual({
|
||||
expect(toEmployeeOption(emp('Без Должности', 'nobody@tnsu.kz', ''))).toEqual({
|
||||
value: 'nobody@tnsu.kz',
|
||||
label: 'Без Должности',
|
||||
sublabel: 'nobody@tnsu.kz'
|
||||
@@ -47,12 +54,12 @@ describe('toEmployeeOption', () => {
|
||||
|
||||
describe('matchEmployeesByPosition', () => {
|
||||
const staff: Employee[] = [
|
||||
{ fio: 'Плотникова Лариса', email: 'p@tnsu.kz', position: 'Генеральный директор', company: 'Компания А' },
|
||||
{ fio: 'Самаева Асель', email: 's@tnsu.kz', position: 'Директор', company: 'Компания А' },
|
||||
{ fio: 'Ким Николай', email: 'k@tnsu.kz', position: 'Управляющий директор', company: 'Компания Б' },
|
||||
{ fio: 'Иванов Иван', email: 'i1@tnsu.kz', position: 'Инженер ПТО', company: 'Компания А' },
|
||||
{ fio: 'Петров Пётр', email: 'i2@tnsu.kz', position: 'Инженер ПТО', company: 'Компания Б' },
|
||||
{ fio: 'Без Должности', email: 'n@tnsu.kz', position: '', company: 'Компания А' }
|
||||
emp('Плотникова Лариса', 'p@tnsu.kz', 'Генеральный директор', 'Компания А'),
|
||||
emp('Самаева Асель', 's@tnsu.kz', 'Директор', 'Компания А'),
|
||||
emp('Ким Николай', 'k@tnsu.kz', 'Управляющий директор', 'Компания Б'),
|
||||
emp('Иванов Иван', 'i1@tnsu.kz', 'Инженер ПТО', 'Компания А'),
|
||||
emp('Петров Пётр', 'i2@tnsu.kz', 'Инженер ПТО', 'Компания Б'),
|
||||
emp('Без Должности', 'n@tnsu.kz', '', 'Компания А')
|
||||
];
|
||||
|
||||
it('пустой ввод — ничего не подбирает', () => {
|
||||
|
||||
@@ -45,7 +45,13 @@ describe('detectExecutorKind', () => {
|
||||
|
||||
describe('describeStepExecutor', () => {
|
||||
const employees: Employee[] = [
|
||||
{ fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик', company: 'ТОО TANSU Construction' }
|
||||
{
|
||||
fio: 'Горбенко Павел',
|
||||
email: 'gorbenko.pavel@tnsu.kz',
|
||||
position: 'Разработчик',
|
||||
company: 'ТОО TANSU Construction',
|
||||
companies: ['ТОО TANSU Construction']
|
||||
}
|
||||
];
|
||||
|
||||
it('для email найденного сотрудника показывает ФИО, а должность и email — отдельной строкой', () => {
|
||||
@@ -57,7 +63,7 @@ describe('describeStepExecutor', () => {
|
||||
// начинаться с висящего разделителя.
|
||||
it('для сотрудника без должности в подписи остаётся только email', () => {
|
||||
const result = describeStepExecutor(step({ executorEmail: 'nopos@tnsu.kz' }), [
|
||||
{ fio: 'Без Должности', email: 'nopos@tnsu.kz', position: '', company: '' }
|
||||
{ fio: 'Без Должности', email: 'nopos@tnsu.kz', position: '', company: '', companies: [] }
|
||||
]);
|
||||
expect(result).toEqual({ primary: 'Без Должности', secondary: 'nopos@tnsu.kz' });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user