Обходной лист #12

Merged
gorbenko.pavel merged 11 commits from clearance-form into main 2026-08-17 05:43:52 +00:00
15 changed files with 118 additions and 162 deletions
Showing only changes of commit 7d5d51982b - Show all commits
@@ -4,9 +4,6 @@ namespace Tnsu.ApprovalRoute.Api.Auth;
public static class AuthSchemes public static class AuthSchemes
{ {
/// <summary>Локальная разработка и тесты — Entra не настроен (см. AuthSetup).</summary>
public const string Dev = "Dev";
/// <summary>Токен пользователя (делегированный) — обычная работа админки.</summary> /// <summary>Токен пользователя (делегированный) — обычная работа админки.</summary>
public const string Bearer = JwtBearerDefaults.AuthenticationScheme; public const string Bearer = JwtBearerDefaults.AuthenticationScheme;
@@ -20,11 +20,11 @@ public static class AuthSetup
var tenantId = configuration["Entra:TenantId"]; var tenantId = configuration["Entra:TenantId"];
var audience = configuration["Entra:Audience"]; var audience = configuration["Entra:Audience"];
if (!string.IsNullOrWhiteSpace(tenantId) && !string.IsNullOrWhiteSpace(audience)) if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(audience))
AddEntraAuth(services, configuration, tenantId, audience); throw new InvalidOperationException(
else "Entra:TenantId и Entra:Audience обязательны — локального Dev-режима без них больше нет.");
AddDevAuth(services);
AddEntraAuth(services, configuration, tenantId, audience);
return services; return services;
} }
@@ -125,26 +125,4 @@ public static class AuthSetup
options.DefaultPolicy = options.GetPolicy(AuthPolicies.Any)!; 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": { "ConnectionStrings": {
"Postgres": "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route" "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", "Audience": "9232d9e2-9807-4753-808f-07250e5474b0",
"AllowedAppIds": [] "AllowedAppIds": []
}, },
"Dev": {
"DefaultUserEmail": "dev@tnsu.kz"
},
"Cors": { "Cors": {
"AllowedOrigins": [ "AllowedOrigins": [
"https://tnsukz.sharepoint.com", "https://tnsukz.sharepoint.com",
@@ -1,11 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import * as ReactDom from 'react-dom'; import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library'; import { Version } from '@microsoft/sp-core-library';
import { import { type IPropertyPaneConfiguration, PropertyPaneTextField } from '@microsoft/sp-property-pane';
type IPropertyPaneConfiguration,
PropertyPaneTextField,
PropertyPaneToggle
} from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base'; import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import ApprovalAdmin from './components/ApprovalAdmin'; import ApprovalAdmin from './components/ApprovalAdmin';
@@ -13,8 +9,6 @@ import { IApprovalAdminProps } from './components/IApprovalAdminProps';
export interface IApprovalAdminWebPartProps { export interface IApprovalAdminWebPartProps {
apiBaseUrl: string; apiBaseUrl: string;
useDevAuth: boolean;
devUserEmail: string;
aadResourceId: string; aadResourceId: string;
portalBaseUrl: string; portalBaseUrl: string;
} }
@@ -25,8 +19,6 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprova
const element: React.ReactElement<IApprovalAdminProps> = React.createElement(ApprovalAdmin, { const element: React.ReactElement<IApprovalAdminProps> = React.createElement(ApprovalAdmin, {
context: this.context, context: this.context,
apiBaseUrl: this.properties.apiBaseUrl, apiBaseUrl: this.properties.apiBaseUrl,
useDevAuth: this.properties.useDevAuth,
devUserEmail: this.properties.devUserEmail,
aadResourceId: this.properties.aadResourceId, aadResourceId: this.properties.aadResourceId,
portalBaseUrl: this.properties.portalBaseUrl portalBaseUrl: this.properties.portalBaseUrl
}); });
@@ -39,11 +31,6 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprova
// Для локальной разработки переопределите значение в панели свойств веб-части // Для локальной разработки переопределите значение в панели свойств веб-части
// (например, на http://localhost:5080 — порт из backend/.../launchSettings.json). // (например, на http://localhost:5080 — порт из backend/.../launchSettings.json).
this.properties.apiBaseUrl = this.properties.apiBaseUrl || 'https://api.tnsu.kz/approval-route'; 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.aadResourceId = this.properties.aadResourceId || 'api://9232d9e2-9807-4753-808f-07250e5474b0';
this.properties.portalBaseUrl = this.properties.portalBaseUrl || 'https://api.tnsu.kz'; this.properties.portalBaseUrl = this.properties.portalBaseUrl || 'https://api.tnsu.kz';
return Promise.resolve(); return Promise.resolve();
@@ -72,15 +59,6 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprova
label: 'Api Base Url', label: 'Api Base Url',
description: 'По умолчанию — прод (https://api.tnsu.kz/approval-route). Для локальной разработки укажите http://localhost:5080.' 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', { PropertyPaneTextField('aadResourceId', {
label: 'AAD Resource Id (App ID URI)', label: 'AAD Resource Id (App ID URI)',
description: 'Должен совпадать с webApiPermissionRequests в package-solution.json. Этим же токеном берутся справочники api.tnsu.kz.' description: 'Должен совпадать с webApiPermissionRequests в package-solution.json. Этим же токеном берутся справочники api.tnsu.kz.'
@@ -3,7 +3,7 @@ import styles from './ApprovalAdmin.module.scss';
import type { IApprovalAdminProps } from './IApprovalAdminProps'; import type { IApprovalAdminProps } from './IApprovalAdminProps';
import { AdminApp } from './app/AdminApp'; import { AdminApp } from './app/AdminApp';
import { ApprovalRouteApi } from '../services/api'; import { ApprovalRouteApi } from '../services/api';
import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient'; import { AadApiHttpClient, IHttpClient } from '../services/httpClient';
import { PortalDictionaryClient } from '../services/portalDictionary'; import { PortalDictionaryClient } from '../services/portalDictionary';
import { SharePointListsClient } from '../services/sharePointLists'; import { SharePointListsClient } from '../services/sharePointLists';
import { RequestManageService } from '../services/requestManageService'; import { RequestManageService } from '../services/requestManageService';
@@ -21,9 +21,6 @@ function ensureFontsLoaded(): void {
} }
async function createHttpClient(props: IApprovalAdminProps): Promise<IHttpClient> { 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); const aadClient = await props.context.aadHttpClientFactory.getClient(props.aadResourceId);
return new AadApiHttpClient(aadClient, props.apiBaseUrl); return new AadApiHttpClient(aadClient, props.apiBaseUrl);
} }
@@ -50,11 +47,8 @@ const ApprovalAdmin: React.FC<IApprovalAdminProps> = props => {
return () => { cancelled = true; }; return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps // 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(() => { React.useEffect(() => {
let cancelled = false; let cancelled = false;
setDictionary(undefined); setDictionary(undefined);
@@ -3,8 +3,6 @@ import { WebPartContext } from '@microsoft/sp-webpart-base';
export interface IApprovalAdminProps { export interface IApprovalAdminProps {
context: WebPartContext; context: WebPartContext;
apiBaseUrl: string; apiBaseUrl: string;
useDevAuth: boolean;
devUserEmail: string;
aadResourceId: string; aadResourceId: string;
portalBaseUrl: string; portalBaseUrl: string;
} }
@@ -223,7 +223,7 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
// бэкенд ищет по компании инициатора). Найденные в других компаниях // бэкенд ищет по компании инициатора). Найденные в других компаниях
// этому маршруту не подойдут — их надо не прятать, а объяснить, что они не в счёт. // этому маршруту не подойдут — их надо не прятать, а объяснить, что они не в счёт.
const inRouteCompany = routeCompanyName const inRouteCompany = routeCompanyName
? positionMatches.exact.filter(e => e.company === routeCompanyName) ? positionMatches.exact.filter(e => e.companies.indexOf(routeCompanyName) !== -1)
: positionMatches.exact; : positionMatches.exact;
if (inRouteCompany.length > 0) { if (inRouteCompany.length > 0) {
@@ -235,12 +235,18 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
: texts.routeSteps.positionPreviewFound(inRouteCompany.length)} : texts.routeSteps.positionPreviewFound(inRouteCompany.length)}
</span> </span>
<ul className={styles.previewList}> <ul className={styles.previewList}>
{inRouteCompany.map(e => ( {inRouteCompany.map(e => {
// Показываем ИМЕННО ту компанию, по которой сотрудник совпал с
// маршрутом — она не обязана быть его company (первой из
// справочника), если он числится в нескольких (см. companies).
const shownCompany = routeCompanyName ?? e.company;
return (
<li key={e.email}> <li key={e.email}>
<span className={styles.previewPerson}>{e.fio}</span> <span className={styles.previewPerson}>{e.fio}</span>
{e.company && <span className={styles.previewCompany}> · {e.company}</span>} {shownCompany && <span className={styles.previewCompany}> · {shownCompany}</span>}
</li> </li>
))} );
})}
</ul> </ul>
</div> </div>
); );
@@ -34,10 +34,17 @@ export interface Employee {
* отображения в списке (сам справочник должностей не показывает). */ * отображения в списке (сам справочник должностей не показывает). */
position: string; position: string;
/** /**
* Компания сотрудника — '' если не пришла из справочника. Нужна подсказке * Первая компания сотрудника из справочника ('' если не пришла) — для
* «кого найдёт эта должность» в форме шага: бэкенд ищет исполнителя по ПАРЕ * отображения в пикере/списках, где на человека нужна ровно одна строка.
* (должность, компания инициатора), поэтому одну и ту же должность в разных
* юрлицах занимают разные люди, и без компании подсказка была бы неоднозначной.
*/ */
company: 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)); .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, fio: x.fio,
email: x.email, email: x.email,
position: x.position || '', position: x.position || '',
company: x.company || '' company: x.company || '',
// Одна строка на каждую компанию, где числится сотрудник (совместители/
// бухгалтерия на несколько юрлиц — не редкость), companies собирает их
// все, а company остаётся первой встреченной (для пикера/отображения,
// где нужна ровно одна строка на человека).
companies: x.company ? [x.company] : []
}); });
} else { } else {
// Один сотрудник — строка на каждый его отдел; должность и компания
// могли найтись не в первой из них.
if (!existing.position && x.position) existing.position = x.position; if (!existing.position && x.position) existing.position = x.position;
if (!existing.company && x.company) existing.company = x.company; 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()); return Array.from(byEmail.values());
@@ -1,9 +1,16 @@
import { Employee } from '../models'; import { Employee } from '../models';
import { findEmployeeByEmail, matchEmployeesByPosition, toEmployeeOption } from './employee'; 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[] = [ const employees: Employee[] = [
{ fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик middle', company: 'ТОО TANSU Construction' }, emp('Горбенко Павел', 'gorbenko.pavel@tnsu.kz', 'Разработчик middle', 'ТОО TANSU Construction'),
{ fio: 'Соколов Давид', email: 'sokolov.david@tnsu.kz', position: 'Руководитель отдела', company: 'ТОО TANSU Construction' } emp('Соколов Давид', 'sokolov.david@tnsu.kz', 'Руководитель отдела', 'ТОО TANSU Construction')
]; ];
describe('findEmployeeByEmail', () => { describe('findEmployeeByEmail', () => {
@@ -37,7 +44,7 @@ describe('toEmployeeOption', () => {
// Должность в справочнике заполнена не у всех — тогда подпись не должна // Должность в справочнике заполнена не у всех — тогда подпись не должна
// начинаться с висящего разделителя. // начинаться с висящего разделителя.
it('без должности в подписи остаётся только email', () => { it('без должности в подписи остаётся только email', () => {
expect(toEmployeeOption({ fio: 'Без Должности', email: 'nobody@tnsu.kz', position: '', company: '' })).toEqual({ expect(toEmployeeOption(emp('Без Должности', 'nobody@tnsu.kz', ''))).toEqual({
value: 'nobody@tnsu.kz', value: 'nobody@tnsu.kz',
label: 'Без Должности', label: 'Без Должности',
sublabel: 'nobody@tnsu.kz' sublabel: 'nobody@tnsu.kz'
@@ -47,12 +54,12 @@ describe('toEmployeeOption', () => {
describe('matchEmployeesByPosition', () => { describe('matchEmployeesByPosition', () => {
const staff: Employee[] = [ const staff: Employee[] = [
{ fio: 'Плотникова Лариса', email: 'p@tnsu.kz', position: 'Генеральный директор', company: 'Компания А' }, emp('Плотникова Лариса', 'p@tnsu.kz', 'Генеральный директор', 'Компания А'),
{ fio: 'Самаева Асель', email: 's@tnsu.kz', position: 'Директор', company: 'Компания А' }, emp('Самаева Асель', 's@tnsu.kz', 'Директор', 'Компания А'),
{ fio: 'Ким Николай', email: 'k@tnsu.kz', position: 'Управляющий директор', company: 'Компания Б' }, emp('Ким Николай', 'k@tnsu.kz', 'Управляющий директор', 'Компания Б'),
{ fio: 'Иванов Иван', email: 'i1@tnsu.kz', position: 'Инженер ПТО', company: 'Компания А' }, emp('Иванов Иван', 'i1@tnsu.kz', 'Инженер ПТО', 'Компания А'),
{ fio: 'Петров Пётр', email: 'i2@tnsu.kz', position: 'Инженер ПТО', company: 'Компания Б' }, emp('Петров Пётр', 'i2@tnsu.kz', 'Инженер ПТО', 'Компания Б'),
{ fio: 'Без Должности', email: 'n@tnsu.kz', position: '', company: 'Компания А' } emp('Без Должности', 'n@tnsu.kz', '', 'Компания А')
]; ];
it('пустой ввод — ничего не подбирает', () => { it('пустой ввод — ничего не подбирает', () => {
@@ -45,7 +45,13 @@ describe('detectExecutorKind', () => {
describe('describeStepExecutor', () => { describe('describeStepExecutor', () => {
const employees: Employee[] = [ 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 — отдельной строкой', () => { it('для email найденного сотрудника показывает ФИО, а должность и email — отдельной строкой', () => {
@@ -57,7 +63,7 @@ describe('describeStepExecutor', () => {
// начинаться с висящего разделителя. // начинаться с висящего разделителя.
it('для сотрудника без должности в подписи остаётся только email', () => { it('для сотрудника без должности в подписи остаётся только email', () => {
const result = describeStepExecutor(step({ executorEmail: 'nopos@tnsu.kz' }), [ 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' }); expect(result).toEqual({ primary: 'Без Должности', secondary: 'nopos@tnsu.kz' });
}); });