From 7d5d51982bfd18a17be162090ec84293422046de Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 14 Aug 2026 14:51:48 +0500 Subject: [PATCH] =?UTF-8?q?dev=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=20=D1=83?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Auth/AuthSchemes.cs | 3 - .../Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs | 30 ++-------- .../Auth/DevAuthHandler.cs | 26 --------- .../appsettings.Development.json | 4 -- .../Tnsu.ApprovalRoute.Api/appsettings.json | 3 - .../approvalAdmin/ApprovalAdminWebPart.ts | 24 +------- .../components/ApprovalAdmin.tsx | 10 +--- .../components/IApprovalAdminProps.ts | 2 - .../components/screens/RouteStepFormModal.tsx | 20 ++++--- .../approvalAdmin/models/dictionaries.ts | 15 +++-- .../approvalAdmin/services/httpClient.ts | 42 -------------- .../services/portalDictionary.test.ts | 56 +++++++++++++++++++ .../services/portalDictionary.ts | 10 +++- .../approvalAdmin/utils/employee.test.ts | 25 ++++++--- .../approvalAdmin/utils/stepExecutor.test.ts | 10 +++- 15 files changed, 118 insertions(+), 162 deletions(-) delete mode 100644 backend/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs create mode 100644 frontend/src/webparts/approvalAdmin/services/portalDictionary.test.ts diff --git a/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs b/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs index 27f8132..638e23c 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs @@ -4,9 +4,6 @@ namespace Tnsu.ApprovalRoute.Api.Auth; public static class AuthSchemes { - /// Локальная разработка и тесты — Entra не настроен (см. AuthSetup). - public const string Dev = "Dev"; - /// Токен пользователя (делегированный) — обычная работа админки. public const string Bearer = JwtBearerDefaults.AuthenticationScheme; diff --git a/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs b/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs index 5d82424..4ff5509 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs @@ -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(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)!; - }); - } } diff --git a/backend/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs b/backend/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs deleted file mode 100644 index 8086a82..0000000 --- a/backend/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs +++ /dev/null @@ -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 options, - ILoggerFactory logger, - UrlEncoder encoder, - IConfiguration configuration) - : AuthenticationHandler(options, logger, encoder) -{ - protected override Task 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)); - } -} diff --git a/backend/Tnsu.ApprovalRoute.Api/appsettings.Development.json b/backend/Tnsu.ApprovalRoute.Api/appsettings.Development.json index 5adccfc..52e99be 100644 --- a/backend/Tnsu.ApprovalRoute.Api/appsettings.Development.json +++ b/backend/Tnsu.ApprovalRoute.Api/appsettings.Development.json @@ -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" } diff --git a/backend/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/Tnsu.ApprovalRoute.Api/appsettings.json index ef7f102..e01d0f1 100644 --- a/backend/Tnsu.ApprovalRoute.Api/appsettings.json +++ b/backend/Tnsu.ApprovalRoute.Api/appsettings.json @@ -13,9 +13,6 @@ "Audience": "9232d9e2-9807-4753-808f-07250e5474b0", "AllowedAppIds": [] }, - "Dev": { - "DefaultUserEmail": "dev@tnsu.kz" - }, "Cors": { "AllowedOrigins": [ "https://tnsukz.sharepoint.com", diff --git a/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.ts b/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.ts index 0b826a2..554cd1a 100644 --- a/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.ts +++ b/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.ts @@ -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 = 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 { - 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 = 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); diff --git a/frontend/src/webparts/approvalAdmin/components/IApprovalAdminProps.ts b/frontend/src/webparts/approvalAdmin/components/IApprovalAdminProps.ts index e01a6e5..fdf929a 100644 --- a/frontend/src/webparts/approvalAdmin/components/IApprovalAdminProps.ts +++ b/frontend/src/webparts/approvalAdmin/components/IApprovalAdminProps.ts @@ -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; } diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx index 046ce26..0d9bd2e 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx @@ -223,7 +223,7 @@ export const RouteStepFormModal: React.FC = ({ // бэкенд ищет по компании инициатора). Найденные в других компаниях // этому маршруту не подойдут — их надо не прятать, а объяснить, что они не в счёт. 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 = ({ : texts.routeSteps.positionPreviewFound(inRouteCompany.length)}
    - {inRouteCompany.map(e => ( -
  • - {e.fio} - {e.company && · {e.company}} -
  • - ))} + {inRouteCompany.map(e => { + // Показываем ИМЕННО ту компанию, по которой сотрудник совпал с + // маршрутом — она не обязана быть его company (первой из + // справочника), если он числится в нескольких (см. companies). + const shownCompany = routeCompanyName ?? e.company; + return ( +
  • + {e.fio} + {shownCompany && · {shownCompany}} +
  • + ); + })}
); diff --git a/frontend/src/webparts/approvalAdmin/models/dictionaries.ts b/frontend/src/webparts/approvalAdmin/models/dictionaries.ts index 8b99a3c..566a2f1 100644 --- a/frontend/src/webparts/approvalAdmin/models/dictionaries.ts +++ b/frontend/src/webparts/approvalAdmin/models/dictionaries.ts @@ -34,10 +34,17 @@ export interface Employee { * отображения в списке (сам справочник должностей не показывает). */ position: string; /** - * Компания сотрудника — '' если не пришла из справочника. Нужна подсказке - * «кого найдёт эта должность» в форме шага: бэкенд ищет исполнителя по ПАРЕ - * (должность, компания инициатора), поэтому одну и ту же должность в разных - * юрлицах занимают разные люди, и без компании подсказка была бы неоднозначной. + * Первая компания сотрудника из справочника ('' если не пришла) — для + * отображения в пикере/списках, где на человека нужна ровно одна строка. */ company: string; + /** + * ВСЕ компании, где числится сотрудник — совместители и штат общих служб + * (бухгалтерия, HR) на несколько юрлиц не редкость: в api.tnsu.kz это + * отдельная строка на каждую компанию, а не одна с списком. Проверять + * принадлежность к конкретной компании (маршрут ограничен ею) нужно по + * этому полю, а не по company — иначе сотрудник, у которого в справочнике + * первой оказалась ДРУГАЯ его компания, ошибочно выглядел бы отсутствующим. + */ + companies: string[]; } diff --git a/frontend/src/webparts/approvalAdmin/services/httpClient.ts b/frontend/src/webparts/approvalAdmin/services/httpClient.ts index 6fcacca..399b1e5 100644 --- a/frontend/src/webparts/approvalAdmin/services/httpClient.ts +++ b/frontend/src/webparts/approvalAdmin/services/httpClient.ts @@ -116,45 +116,3 @@ export class AadApiHttpClient implements IHttpClient { .then((r: HttpClientResponse) => parseResponse(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(path: string, query?: QueryParams): Promise { - return this.send('GET', path, undefined, query); - } - - public post(path: string, body?: unknown): Promise { - return this.send('POST', path, body); - } - - public put(path: string, body?: unknown): Promise { - return this.send('PUT', path, body); - } - - public patch(path: string, body?: unknown): Promise { - return this.send('PATCH', path, body); - } - - public async delete(path: string): Promise { - await this.send('DELETE', path); - } - - private async send(method: string, path: string, body?: unknown, query?: QueryParams): Promise { - 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(response); - } -} diff --git a/frontend/src/webparts/approvalAdmin/services/portalDictionary.test.ts b/frontend/src/webparts/approvalAdmin/services/portalDictionary.test.ts new file mode 100644 index 0000000..e83adb1 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/services/portalDictionary.test.ts @@ -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([]); + }); +}); diff --git a/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts b/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts index 9f25130..b1c1756 100644 --- a/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts +++ b/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts @@ -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()); diff --git a/frontend/src/webparts/approvalAdmin/utils/employee.test.ts b/frontend/src/webparts/approvalAdmin/utils/employee.test.ts index 7005da0..a5eb1b3 100644 --- a/frontend/src/webparts/approvalAdmin/utils/employee.test.ts +++ b/frontend/src/webparts/approvalAdmin/utils/employee.test.ts @@ -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('пустой ввод — ничего не подбирает', () => { diff --git a/frontend/src/webparts/approvalAdmin/utils/stepExecutor.test.ts b/frontend/src/webparts/approvalAdmin/utils/stepExecutor.test.ts index 6cd149c..0250f1c 100644 --- a/frontend/src/webparts/approvalAdmin/utils/stepExecutor.test.ts +++ b/frontend/src/webparts/approvalAdmin/utils/stepExecutor.test.ts @@ -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' }); });