test: cover report form rules, save order and SharePoint calls

Checked line by line against stroy-bot-report: form modes, validation
texts and order, payloads, delete-children-first then recreate save
sequence, photo upload, access lookup and dictionary fallbacks. The fake
HTTP client can now answer per call so created rows get distinct ids.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-16 10:08:07 +05:00
parent 673ace2ba6
commit 072d246eed
9 changed files with 657 additions and 11 deletions
@@ -1,8 +0,0 @@
import * as React from 'react';
import { EmptyState } from '../shared/components/EmptyState';
import { APP_TEXTS } from './texts';
/** Временная заглушка для разделов, которые ещё не перенесены. Удаляется вместе с последним разделом. */
export const SectionPlaceholder: React.FC = () => (
<EmptyState icon="refresh" title={APP_TEXTS.sectionInProgress} text={APP_TEXTS.sectionInProgressHint} />
);
@@ -0,0 +1,204 @@
import {
buildReportDataPayload,
createDataverseReportRepository,
DV_PATHS,
orderRowsForDeletion
} from './dataverseReportRepository';
import { createFakeHttpClient, IFakeRoute } from '../../shared/testing/fakeHttpClient';
import { IReportPayload } from './reportTypes';
const DV = 'https://tansu.crm5.dynamics.com';
const API = `${DV}/api/data/v9.2/`;
const REPORT: IReportPayload = {
cree9_objectid: 41,
cree9_companyid: 3,
cree9_shiftid: 1,
cree9_report_date: '2026-10-16',
cree9_number: 8,
cree9_projectid: 5,
cree9_comment: undefined
};
/** Тело запроса как объект — чтобы сравнивать без зависимости от порядка ключей. */
const bodyOf = (body: string | ArrayBuffer | undefined): unknown => JSON.parse(String(body));
describe('адреса Dataverse (1:1 со stroy-bot-report)', () => {
it('отчёт, счётчик, строки, факт', () => {
expect(DV_PATHS.report('r1')).toEqual('cree9_reports(r1)');
expect(DV_PATHS.reportCount(5)).toEqual(
'cree9_reports?$filter=cree9_projectid eq 5 and cree9_deleted ne true&$count=true&$top=1&$select=cree9_number'
);
expect(DV_PATHS.reportData('r1')).toEqual('cree9_report_datas?$filter=_cree9_column1_value eq r1');
expect(DV_PATHS.jobTotalFact('j1')).toEqual('cree9_report_datas?$filter=_cree9_jobid_value eq j1&$select=cree9_value');
expect(DV_PATHS.jobsByObject(41)).toEqual(
"cree9_tables?$filter=cree9_objectid eq '41'" +
'&$select=cree9_tableid,cree9_title,cree9_unit,cree9_objectid,cree9_volume,_cree9_parentid_value'
);
});
});
describe('buildReportDataPayload', () => {
it('работа — через cree9_jobId@odata.bind, всегда привязка к отчёту, пустые поля пропускаются', () => {
expect(buildReportDataPayload({ _cree9_jobid_value: 'j1', cree9_value: 2, cree9_comment: undefined }, 'r1')).toEqual({
'cree9_jobId@odata.bind': '/cree9_tables(j1)',
cree9_value: 2,
'cree9_Column1@odata.bind': '/cree9_reports(r1)'
});
});
it('строка на работе привязана к строке-работе через cree9_parentId', () => {
expect(buildReportDataPayload({ cree9_professionid: 7, cree9_count: 2 }, 'r1', 'row-w')).toEqual({
cree9_professionid: 7,
cree9_count: 2,
'cree9_Column1@odata.bind': '/cree9_reports(r1)',
'cree9_parentId@odata.bind': '/cree9_report_datas(row-w)'
});
});
});
describe('orderRowsForDeletion', () => {
it('сначала дочерние (с родителем), потом остальные', () => {
const ordered = orderRowsForDeletion([
{ cree9_report_dataid: 'parent' },
{ cree9_report_dataid: 'child', _cree9_parentid_value: 'parent' },
{ cree9_report_dataid: 'general' }
]);
expect(ordered.map((row) => row.cree9_report_dataid)).toEqual(['child', 'parent', 'general']);
});
});
describe('saveReportWithData', () => {
const nodes = [
{ data: { _cree9_jobid_value: 'j1', cree9_value: 2 }, children: [{ cree9_professionid: 7, cree9_count: 1 }] },
{ data: { cree9_technicid: 9, cree9_count: 1 } }
];
/** Каждая созданная строка отчёта получает свой id: row-1, row-2, … */
const createdRows: IFakeRoute = {
match: 'cree9_report_datas',
respond: (callNumber) => ({ status: 201, body: { cree9_report_dataid: `row-${callNumber}` } })
};
it('новый отчёт: POST отчёта, затем строки по порядку, дети — к строке своей работы', async () => {
const client = createFakeHttpClient([
createdRows,
{ match: 'cree9_reports', response: { status: 201, body: { cree9_reportid: 'r-new' } } }
]);
const repository = createDataverseReportRepository(client, DV);
expect(await repository.saveReportWithData(REPORT, nodes)).toEqual('r-new');
const writes = client.requests;
expect(writes.map((r) => r.url)).toEqual([
`${API}cree9_reports`,
`${API}cree9_report_datas`,
`${API}cree9_report_datas`,
`${API}cree9_report_datas`
]);
expect(bodyOf(writes[0].request.body)).toEqual({
cree9_objectid: 41,
cree9_companyid: 3,
cree9_shiftid: 1,
cree9_report_date: '2026-10-16',
cree9_number: 8,
cree9_projectid: 5
});
// Ребёнок работы (второй POST строки) привязан к строке работы row-1.
expect(bodyOf(writes[2].request.body)).toEqual(
expect.objectContaining({ 'cree9_parentId@odata.bind': '/cree9_report_datas(row-1)' })
);
// Общая техника — без родителя.
expect(bodyOf(writes[3].request.body)).not.toHaveProperty('cree9_parentId@odata.bind');
});
it('правка: PATCH отчёта → удаление старых строк (дети первыми) → создание новых', async () => {
const client = createFakeHttpClient([
{
match: '?$filter=_cree9_column1_value eq r1',
response: {
body: {
value: [
{ cree9_report_dataid: 'old-work' },
{ cree9_report_dataid: 'old-child', _cree9_parentid_value: 'old-work' }
]
}
}
},
{ match: 'cree9_report_datas(', response: { status: 204 } },
createdRows,
{ match: 'cree9_reports(r1)', response: { status: 204 } }
]);
const repository = createDataverseReportRepository(client, DV);
expect(await repository.saveReportWithData(REPORT, nodes, 'r1')).toEqual('r1');
const sequence = client.requests.map((r) => `${r.request.method} ${r.url.replace(API, '')}`);
expect(sequence).toEqual([
'PATCH cree9_reports(r1)',
'GET cree9_report_datas?$filter=_cree9_column1_value eq r1',
'DELETE cree9_report_datas(old-child)',
'DELETE cree9_report_datas(old-work)',
'POST cree9_report_datas',
'POST cree9_report_datas',
'POST cree9_report_datas'
]);
});
it('если сервер не вернул id отчёта — ошибка, строки не создаются', async () => {
const client = createFakeHttpClient([{ match: 'cree9_reports', response: { status: 201, body: {} } }]);
const repository = createDataverseReportRepository(client, DV);
await expect(repository.saveReportWithData(REPORT, nodes)).rejects.toThrow('reportId is empty');
expect(client.requests).toHaveLength(1);
});
it('ошибка удаления старой строки прерывает сохранение', async () => {
const client = createFakeHttpClient([
{ match: '_cree9_column1_value', response: { body: { value: [{ cree9_report_dataid: 'old' }] } } },
{ match: 'cree9_report_datas(old)', response: { status: 403 } },
{ match: 'cree9_reports(r1)', response: { status: 204 } }
]);
const repository = createDataverseReportRepository(client, DV);
await expect(repository.saveReportWithData(REPORT, nodes, 'r1')).rejects.toThrow('403');
expect(client.requests.some((r) => r.request.method === 'POST')).toBe(false);
});
});
describe('прочие операции', () => {
it('номер нового отчёта = количество неудалённых + 1', async () => {
const client = createFakeHttpClient([{ match: '$count=true', response: { body: { '@odata.count': 7, value: [] } } }]);
expect(await createDataverseReportRepository(client, DV).getNextReportNumber(5)).toEqual(8);
});
it('утверждение — PATCH cree9_aprooved: true', async () => {
const client = createFakeHttpClient([{ match: 'cree9_reports(r1)', response: { status: 204 } }]);
await createDataverseReportRepository(client, DV).approveReport('r1');
expect(client.requests[0].request.method).toEqual('PATCH');
expect(bodyOf(client.requests[0].request.body)).toEqual({ cree9_aprooved: true });
});
it('итоговый факт — сумма cree9_value по всем строкам работы', async () => {
const client = createFakeHttpClient([
{ match: '_cree9_jobid_value', response: { body: { value: [{ cree9_value: 2 }, {}, { cree9_value: 1.5 }] } } }
]);
expect(await createDataverseReportRepository(client, DV).getJobTotalFact('j1')).toEqual(3.5);
});
it('новая работа: объём только если > 0, родитель — через найденное навигационное свойство', async () => {
const client = createFakeHttpClient([
{ match: 'EntityDefinitions', response: { body: { value: [{ ReferencingEntityNavigationPropertyName: 'cree9_ParentId' }] } } },
{ match: 'cree9_tables', response: { status: 201, body: { cree9_tableid: 'new-job' } } }
]);
const repository = createDataverseReportRepository(client, DV);
const job = await repository.createJob({ title: 'Бетон', unit: 'м3', objectId: 41, volume: 0, parentId: 'a' });
expect(job.cree9_tableid).toEqual('new-job');
expect(bodyOf(client.requests[1].request.body)).toEqual({
cree9_title: 'Бетон',
cree9_unit: 'м3',
cree9_objectid: '41',
'cree9_ParentId@odata.bind': '/cree9_tables(a)'
});
});
});
@@ -0,0 +1,50 @@
import { buildFormCollections } from './reportCollections';
import { IReportServices } from './reportServices';
import { IReportDataRow } from './reportTypes';
/** Сервисы-заглушки: только то, что использует разбор строк. */
function fakeServices(): IReportServices {
const sp = {
getProfessionById: async (id: number) => ({ Id: id, Title: id === 7 ? 'Сварщик' : 'Монтажник' }),
getTechnicById: async (id: number) => {
if (id === 99) throw new Error('удалена');
return { Id: id, Title: 'Кран' };
}
};
const dv = {
getJobsByObjectId: async () => [
{ cree9_tableid: 'j1', cree9_title: 'Бетон', cree9_unit: 'м3', cree9_objectid: '41', cree9_volume: 10, fullPath: 'Бетон' }
],
getJobTotalFact: async () => 6
};
return { sp, dv, siteUrl: '' } as unknown as IReportServices;
}
describe('buildFormCollections (как buildFormCollections старой формы)', () => {
const rows: IReportDataRow[] = [
// Ребёнок раньше своей работы — всё равно прикрепляется к ней.
{ cree9_report_dataid: 'c1', _cree9_parentid_value: 'w1', cree9_professionid: 7, cree9_count: 2, cree9_hours: 8 },
{ cree9_report_dataid: 'w1', _cree9_jobid_value: 'j1', cree9_value: 3, cree9_comment: 'залили' },
// Старый бот писал название в комментарий — такой комментарий не показывается.
{ cree9_report_dataid: 'p1', cree9_professionid: 8, cree9_comment: 'Монтажник', cree9_hourstype: 'Часы простоя' },
// Элемент справочника удалён — без названия, без падения.
{ cree9_report_dataid: 't1', cree9_technicid: 99, cree9_comment: 'сломался' }
];
it('работы, дети работ и общие секции', async () => {
const result = await buildFormCollections(fakeServices(), rows, 41);
expect(result.works).toHaveLength(1);
expect(result.works[0]).toEqual(
expect.objectContaining({ jobId: 'j1', jobTitle: 'Бетон', jobUnit: 'м3', value: 3, plan: 10, totalFact: 6, comment: 'залили' })
);
expect(result.works[0].professions.map((p) => p.title)).toEqual(['Сварщик']);
expect(result.professions).toEqual([
expect.objectContaining({ resourceId: 8, title: 'Монтажник', comment: '', hoursType: 'Часы простоя' })
]);
expect(result.technics).toEqual([
expect.objectContaining({ resourceId: 99, title: '', comment: 'сломался', hoursType: 'Часы работы' })
]);
});
});
@@ -0,0 +1,51 @@
import { buildReportNodes, buildReportPayload } from './reportPayload';
import { reportForm, resource, workItem } from './reportTestData';
describe('buildReportPayload', () => {
it('поля отчёта как в handleSave, пустой комментарий не отправляется', () => {
expect(buildReportPayload(reportForm(), 8, 5)).toEqual({
cree9_objectid: 41,
cree9_companyid: 3,
cree9_shiftid: 1,
cree9_report_date: '2026-10-16',
cree9_number: 8,
cree9_projectid: 5,
cree9_comment: undefined
});
expect(buildReportPayload(reportForm({ comment: 'Дождь' }), 8, 5).cree9_comment).toEqual('Дождь');
});
});
describe('buildReportNodes (порядок строк как в handleSave)', () => {
it('работы с детьми → общий персонал → общая техника', () => {
const form = reportForm({
works: [
workItem({
comment: 'залили',
professions: [resource({ resourceId: 7, hoursType: 'Часы простоя', comment: 'x' })],
technics: [resource({ resourceId: 9, count: 1, hours: 4 })]
}),
workItem({ id: 'w2', jobId: 'job-2', value: 1 })
],
professions: [resource({ resourceId: 11, hoursType: 'Часы простоя', comment: 'ждали' })],
technics: [resource({ resourceId: 12, hoursType: '' })]
});
expect(buildReportNodes(form)).toEqual([
{
data: { _cree9_jobid_value: 'job-1', cree9_value: 3.5, cree9_comment: 'залили' },
children: [
// На работе тип часов всегда «Часы работы», даже если в элементе другой.
{ cree9_professionid: 7, cree9_count: 2, cree9_hours: 8, cree9_comment: 'x', cree9_hourstype: 'Часы работы' },
{ cree9_technicid: 9, cree9_count: 1, cree9_hours: 4, cree9_comment: undefined, cree9_hourstype: 'Часы работы' }
]
},
{ data: { _cree9_jobid_value: 'job-2', cree9_value: 1, cree9_comment: undefined }, children: undefined },
{
data: { cree9_professionid: 11, cree9_count: 2, cree9_hours: 8, cree9_comment: 'ждали', cree9_hourstype: 'Часы простоя' }
},
// Пустой тип часов — «Часы работы».
{ data: { cree9_technicid: 12, cree9_count: 2, cree9_hours: 8, cree9_comment: undefined, cree9_hourstype: 'Часы работы' } }
]);
});
});
@@ -0,0 +1,136 @@
import {
buildJobPaths,
buildObjectPaths,
canShowBackAfterSave,
canShowSaveButton,
daysSinceReport,
decideExistingReportMode,
getStatusBadge,
hasAnyAccess,
isFormReadOnly,
newJobPath,
parseReportRoute,
sortTechnics,
todayIsoDate
} from './reportRules';
import { access } from './reportTestData';
const DAY = 24 * 60 * 60 * 1000;
describe('parseReportRoute', () => {
it('projectId через parseInt, reportId «null» — нет отчёта', () => {
expect(parseReportRoute({ page: 'report', projectId: '12', reportId: 'null' })).toEqual({
projectId: 12,
reportId: undefined,
copyFromId: ''
});
expect(parseReportRoute({ page: 'report', projectId: '12abc', copyFromId: 'c1' }).projectId).toEqual(12);
expect(parseReportRoute({ page: 'report' }).projectId).toEqual(0);
});
});
describe('decideExistingReportMode (как loadExistingReport)', () => {
it('утверждающий + неутверждённый — утверждение, даже если есть право правки', () => {
expect(decideExistingReportMode(access({ aproove: true, edit: true }), false, 0, 5)).toEqual('approve');
});
it('редактор + неутверждённый + меньше editDays дней — правка', () => {
expect(decideExistingReportMode(access({ edit: true }), false, 4, 5)).toEqual('edit');
});
it('прошло editDays дней — только просмотр', () => {
expect(decideExistingReportMode(access({ edit: true }), false, 5, 5)).toEqual('readonly');
});
it('утверждённый отчёт — просмотр для всех', () => {
expect(decideExistingReportMode(access({ edit: true, aproove: true }), true, 0, 5)).toEqual('readonly');
});
it('только чтение — просмотр', () => {
expect(decideExistingReportMode(access({ read: true }), false, 0, 5)).toEqual('readonly');
});
});
describe('доступ и кнопки', () => {
it('нет записи или все права false — нет доступа', () => {
expect(hasAnyAccess(undefined)).toBe(false);
expect(hasAnyAccess(access())).toBe(false);
expect(hasAnyAccess(access({ read: true }))).toBe(true);
});
it('просмотр и утверждение — форма только для чтения', () => {
expect(isFormReadOnly('readonly')).toBe(true);
expect(isFormReadOnly('approve')).toBe(true);
expect(isFormReadOnly('edit')).toBe(false);
});
it('бейдж: у нового нет, утверждён, на утверждении — только у утверждающего', () => {
expect(getStatusBadge('create', true, access({ aproove: true }))).toBeUndefined();
expect(getStatusBadge('readonly', true, access())).toEqual('approved');
expect(getStatusBadge('approve', false, access({ aproove: true }))).toEqual('pending');
expect(getStatusBadge('edit', false, access({ edit: true }))).toBeUndefined();
});
it('«Сохранить»: 🐞 новый отчёт без строки «Доступы» сохранить можно, edit=false — нельзя', () => {
expect(canShowSaveButton('create', false, undefined)).toBe(true);
expect(canShowSaveButton('create', false, access({ edit: false }))).toBe(false);
expect(canShowSaveButton('edit', false, access({ edit: true }))).toBe(true);
expect(canShowSaveButton('edit', true, access({ edit: true }))).toBe(false);
expect(canShowSaveButton('readonly', false, access({ edit: true }))).toBe(false);
});
it('после сохранения — «Вернуться назад»', () => {
expect(canShowBackAfterSave('create', true)).toBe(true);
expect(canShowBackAfterSave('approve', true)).toBe(false);
});
});
describe('даты', () => {
it('сегодня — по UTC, как в старом коде', () => {
expect(todayIsoDate(new Date('2026-10-16T23:30:00Z'))).toEqual('2026-10-16');
});
it('полные сутки с даты отчёта', () => {
const reportDate = '2026-10-10T00:00:00Z';
expect(daysSinceReport(reportDate, new Date(reportDate).getTime() + 5 * DAY - 1)).toEqual(4);
expect(daysSinceReport(reportDate, new Date(reportDate).getTime() + 5 * DAY)).toEqual(5);
});
});
describe('пути', () => {
it('объекты: «Пятно / Раздел», сортировка по пути', () => {
const paths = buildObjectPaths([
{ Id: 2, Title: 'Раздел', parentId: 1 },
{ Id: 1, Title: 'Пятно' },
{ Id: 3, Title: 'Арка' }
]);
expect(paths.map((p) => p.fullPath)).toEqual(['Арка', 'Пятно', 'Пятно / Раздел']);
});
it('работы: путь через родителей, цикл не зацикливает', () => {
const paths = buildJobPaths([
{ cree9_tableid: 'a', cree9_title: 'Фундамент', cree9_unit: '', cree9_objectid: '41' },
{ cree9_tableid: 'b', cree9_title: 'Бетон', cree9_unit: 'м3', cree9_objectid: '41', _cree9_parentid_value: 'a' },
{ cree9_tableid: 'x', cree9_title: 'X', cree9_unit: '', cree9_objectid: '41', _cree9_parentid_value: 'y' },
{ cree9_tableid: 'y', cree9_title: 'Y', cree9_unit: '', cree9_objectid: '41', _cree9_parentid_value: 'x' }
]);
expect(paths.filter((p) => p.cree9_tableid === 'b')[0].fullPath).toEqual('Фундамент / Бетон');
expect(paths.filter((p) => p.cree9_tableid === 'x')[0].fullPath).toEqual('Y / X');
});
it('путь новой работы — от найденного родителя', () => {
const jobs = buildJobPaths([{ cree9_tableid: 'a', cree9_title: 'Фундамент', cree9_unit: '', cree9_objectid: '41' }]);
expect(newJobPath('Бетон', 'a', jobs)).toEqual('Фундамент / Бетон');
expect(newJobPath('Бетон', 'нет', jobs)).toEqual('Бетон');
expect(newJobPath('Бетон', undefined, jobs)).toEqual('Бетон');
});
it('техника: по группе, затем по названию', () => {
const sorted = sortTechnics([
{ Id: 1, Title: 'Экскаватор', group: 'Земляные' },
{ Id: 2, Title: 'Автокран', group: 'Краны' },
{ Id: 3, Title: 'Бульдозер', group: 'Земляные' }
]);
expect(sorted.map((t) => t.Title)).toEqual(['Бульдозер', 'Экскаватор', 'Автокран']);
});
});
@@ -0,0 +1,56 @@
import { IReportAccess, IReportFormState, IResourceItem, IWorkItem } from './reportTypes';
// Заготовки данных для тестов формы отчёта (используются только в *.test.ts).
export const access = (overrides: Partial<IReportAccess> = {}): IReportAccess => ({
id: 1,
userId: 42,
project: '5',
read: false,
edit: false,
aproove: false,
...overrides
});
export const resource = (overrides: Partial<IResourceItem> = {}): IResourceItem => ({
id: 'r1',
resourceId: 7,
title: 'Сварщик',
count: 2,
hours: 8,
hoursType: 'Часы работы',
comment: '',
...overrides
});
export const workItem = (overrides: Partial<IWorkItem> = {}): IWorkItem => ({
id: 'w1',
jobId: 'job-1',
jobTitle: 'Бетон',
jobUnit: 'м3',
value: 3.5,
plan: 10,
totalFact: 4,
comment: '',
professions: [],
technics: [],
...overrides
});
export const reportForm = (overrides: Partial<IReportFormState> = {}): IReportFormState => ({
objectId: 41,
objectPath: 'Пятно / Раздел',
companyId: 3,
companyName: 'ТОО Строй',
shiftId: 1,
shiftName: 'Дневная',
reportDate: '2026-10-16',
reportNumber: 0,
comment: '',
works: [],
professions: [],
technics: [],
photos: [],
photoFolderUrl: undefined,
...overrides
});
@@ -0,0 +1,43 @@
import { validateReportForm } from './reportValidation';
import { reportForm, resource, workItem } from './reportTestData';
describe('validateReportForm (тексты и порядок как в старом validate)', () => {
it('пустая форма — все обязательные поля по порядку', () => {
expect(
validateReportForm(reportForm({ objectId: undefined, companyId: undefined, shiftId: undefined, reportDate: '' }))
).toEqual([
'Выберите объект',
'Выберите компанию',
'Выберите смену',
'Укажите дату отчета',
'Добавьте хотя бы одну работу, профессию или технику'
]);
});
it('достаточно одной работы', () => {
expect(validateReportForm(reportForm({ works: [workItem()] }))).toEqual([]);
});
it('простой персонала без комментария', () => {
const form = reportForm({ professions: [resource({ hoursType: 'Часы простоя', comment: ' ' })] });
expect(validateReportForm(form)).toEqual(['Персонал #1 (Сварщик): укажите комментарий для типа "Часы простоя"']);
});
it('простой и ремонт техники без комментария, номер — позиция в списке', () => {
const form = reportForm({
technics: [
resource({ title: 'Кран', hoursType: 'Часы работы' }),
resource({ title: 'Экскаватор', hoursType: 'Часы ремонта' }),
resource({ title: 'Бульдозер', hoursType: 'Часы простоя', comment: 'ждали бетон' })
]
});
expect(validateReportForm(form)).toEqual(['Техника #2 (Экскаватор): укажите комментарий для типа "Часы ремонта"']);
});
it('персонал и техника внутри работы не проверяются (у них нет типа часов)', () => {
const form = reportForm({
works: [workItem({ professions: [resource({ hoursType: 'Часы простоя' })] })]
});
expect(validateReportForm(form)).toEqual([]);
});
});
@@ -0,0 +1,106 @@
import {
createSharePointReportRepository,
fileAddUrl,
itemsQueryUrl,
photoFolderServerRelativeUrl,
pickAccess
} from './sharePointReportRepository';
import { createFakeHttpClient, IFakeRoute } from '../../shared/testing/fakeHttpClient';
const SITE = 'https://tnsukz.sharepoint.com/sites/stroy';
const repositoryWith = (routes: IFakeRoute[]): {
client: ReturnType<typeof createFakeHttpClient>;
repository: ReturnType<typeof createSharePointReportRepository>;
} => {
const client = createFakeHttpClient(routes);
const repository = createSharePointReportRepository({
client,
siteUrl: SITE,
userId: 42,
webServerRelativeUrl: '/sites/stroy/'
});
return { client, repository };
};
describe('адреса SharePoint (1:1 со stroy-bot-report)', () => {
it('$top=5000 первым, затем $filter и $select', () => {
expect(itemsQueryUrl(SITE, 'Объекты', { filter: "project eq 'Сункар'", select: 'Id,Title,parentId' })).toEqual(
`${SITE}/_api/web/lists/getbytitle('Объекты')/items?$top=5000&$filter=project eq 'Сункар'&$select=Id,Title,parentId`
);
});
it('папка фото DocLib/{projectId}_{номер}, апостроф в имени файла удваивается', () => {
const folder = photoFolderServerRelativeUrl('/sites/stroy/', 5, 8);
expect(folder).toEqual('/sites/stroy/DocLib/5_8');
expect(fileAddUrl(SITE, folder, "it's.jpg")).toEqual(
`${SITE}/_api/web/GetFolderByServerRelativeUrl('/sites/stroy/DocLib/5_8')/Files/add(url='it''s.jpg',overwrite=true)`
);
});
});
describe('pickAccess', () => {
const row = { Id: 1, projectId: 5, read: true, edit0: false, aproove: false };
it('поле пользователя ищется под разными именами', () => {
expect(pickAccess([{ ...row, userIdId: 42 }], 42)?.read).toBe(true);
expect(pickAccess([{ ...row, UserId: 42 }], 42)?.read).toBe(true);
expect(pickAccess([{ ...row, userId: 7 }], 42)).toBeUndefined();
});
it('edit0 → edit', () => {
expect(pickAccess([{ ...row, userId: 42, edit0: true }], 42)).toEqual({
id: 1,
userId: 42,
project: '5',
read: true,
edit: true,
aproove: false
});
});
});
describe('справочники «по проекту»', () => {
const allCompanies = { match: "getbytitle('Компании')", response: { body: { value: [{ Id: 1, Title: 'А' }, { Id: 2, Title: 'Б' }] } } };
it('есть привязки — только привязанные компании', async () => {
const { repository } = repositoryWith([
{ match: 'Компании по проекту', response: { body: { value: [{ companyIdId: 2 }] } } },
allCompanies
]);
expect((await repository.getCompaniesWithFallback(5)).map((c) => c.Id)).toEqual([2]);
});
it('привязок нет или список недоступен — все компании', async () => {
const empty = repositoryWith([{ match: 'Компании по проекту', response: { body: { value: [] } } }, allCompanies]);
const broken = repositoryWith([{ match: 'Компании по проекту', response: { status: 404 } }, allCompanies]);
expect(await empty.repository.getCompaniesWithFallback(5)).toHaveLength(2);
expect(await broken.repository.getCompaniesWithFallback(5)).toHaveLength(2);
});
});
describe('uploadPhotos', () => {
const photo = { name: 'a.jpg', preview: '', buffer: new ArrayBuffer(4) };
it('папки нет — создаётся, затем файлы; возвращается ссылка на папку', async () => {
const { client, repository } = repositoryWith([
{ match: '/Files/add', response: { status: 200 } },
{ match: 'GetFolderByServerRelativeUrl', response: { status: 404 } },
{ match: '/_api/web/folders', response: { status: 201 } }
]);
expect(await repository.uploadPhotos(5, 8, [photo])).toEqual(`${SITE}/DocLib/5_8`);
expect(client.requests.map((r) => r.request.method ?? 'GET')).toEqual(['GET', 'POST', 'POST']);
expect(JSON.parse(String(client.requests[1].request.body))).toEqual({ ServerRelativeUrl: '/sites/stroy/DocLib/5_8' });
expect(client.requests[2].request.headers).toEqual(expect.objectContaining({ 'Content-Type': 'application/octet-stream' }));
});
it('папка уже есть — не создаётся', async () => {
const { client, repository } = repositoryWith([
{ match: '/Files/add', response: { status: 200 } },
{ match: 'GetFolderByServerRelativeUrl', response: { status: 200 } }
]);
await repository.uploadPhotos(5, 8, [photo]);
expect(client.requests.some((r) => r.url.endsWith('/_api/web/folders'))).toBe(false);
});
});
@@ -13,10 +13,15 @@ export interface IFakeResponse {
body?: unknown; body?: unknown;
} }
/** Правило ответа: если URL содержит `match` — вернуть `response`. Проверяются по порядку. */ /**
* Правило ответа: если URL содержит `match` — вернуть `response`. Правила проверяются по порядку.
* Вместо `response` можно передать `respond(callNumber)` — ответ, который зависит от номера вызова
* этого правила (1, 2, 3…), например чтобы каждая созданная строка получала свой id.
*/
export interface IFakeRoute { export interface IFakeRoute {
match: string; match: string;
response: IFakeResponse; response?: IFakeResponse;
respond?: (callNumber: number) => IFakeResponse;
} }
export interface IFakeHttpClient extends IHttpClient { export interface IFakeHttpClient extends IHttpClient {
@@ -37,6 +42,7 @@ function toResponse({ status = 200, body }: IFakeResponse): IHttpResponse {
export function createFakeHttpClient(routes: IFakeRoute[] = []): IFakeHttpClient { export function createFakeHttpClient(routes: IFakeRoute[] = []): IFakeHttpClient {
const requests: IRecordedRequest[] = []; const requests: IRecordedRequest[] = [];
const callCounts = new Map<IFakeRoute, number>();
return { return {
requests, requests,
fetch: async (url: string, request: IHttpRequest = {}): Promise<IHttpResponse> => { fetch: async (url: string, request: IHttpRequest = {}): Promise<IHttpResponse> => {
@@ -45,7 +51,9 @@ export function createFakeHttpClient(routes: IFakeRoute[] = []): IFakeHttpClient
if (!route) { if (!route) {
throw new Error(`fakeHttpClient: нет ответа для ${url}`); throw new Error(`fakeHttpClient: нет ответа для ${url}`);
} }
return toResponse(route.response); const callNumber = (callCounts.get(route) ?? 0) + 1;
callCounts.set(route, callNumber);
return toResponse(route.respond ? route.respond(callNumber) : route.response ?? {});
} }
}; };
} }