From 0d51973a7b010fd55d597a3234b3c40ab8783a1e Mon Sep 17 00:00:00 2001 From: coopops Date: Tue, 16 Jun 2026 07:30:10 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(capital):=20=D1=80=D0=B5=D0=B3=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BB=D0=B8=20=D0=91=D0=BB=D0=B0=D0=B3=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B0=20=D0=BF=D1=80=D0=B8=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=B0=20=D0=BF=D0=BE=20=D1=81=D0=BE=D0=B1=D1=8B?= =?UTF-8?q?=D1=82=D0=B8=D1=8E,=20=D0=B1=D0=B5=D0=B7=20=D0=BE=D0=B6=D0=B8?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BA=D1=80=D0=BE=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Доли пайщиков Благороста регистрировались в проект только периодическим scheduler'ом (regshare) и точечным листенером на дельту кошелька. Новый проект баланс не меняет → wallet-листенер молчит, и наполнение нового проекта зависело ТОЛЬКО от крона. Если компонент быстро прогнали pending→…→result до тика крона, пайщики в него не попадали, а откат result→active контракт не допускает — доли в компоненте терялись безвозвратно (voskhod, компонент 011bcd92…, 2026-06-16). - Новый ProgramShareRegistrationOnProjectDeltaListener на delta::capital::projects: при появлении проекта в статусе pending|active сразу регистрирует доли всех активных пайщиков (неблокирующе, fire-and-forget, переиспользуя ядро syncContributor — тот же путь, что и крон). - ProgramShareRegistrationService.syncProgramSharesForProject — обход пайщиков по одному проекту; syncContributor переведён с ProjectDomainEntity[] на projectHashes: string[]. - Крон ОСТАВЛЕН как reconciliation-бэкстоп: он ещё и ДОобновляет уже зарегистрированные доли при дрейфе баланса (контракт upsert_contributor_segment это допускает) и подбирает события, потерянные при downtime контроллера. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-registration-on-project-delta.listener.ts | 62 +++++++++++++++++ .../program-share-registration.service.ts | 43 +++++++++--- .../capital/capital-extension.module.ts | 2 + ...am-share-registration-scheduler.service.ts | 9 ++- ...stration-on-project-delta.listener.test.ts | 68 +++++++++++++++++++ 5 files changed, 175 insertions(+), 9 deletions(-) create mode 100644 components/controller/src/extensions/capital/application/listeners/program-share-registration-on-project-delta.listener.ts create mode 100644 components/controller/tests/unit/capital/program-share-registration-on-project-delta.listener.test.ts diff --git a/components/controller/src/extensions/capital/application/listeners/program-share-registration-on-project-delta.listener.ts b/components/controller/src/extensions/capital/application/listeners/program-share-registration-on-project-delta.listener.ts new file mode 100644 index 00000000000..531745c85b6 --- /dev/null +++ b/components/controller/src/extensions/capital/application/listeners/program-share-registration-on-project-delta.listener.ts @@ -0,0 +1,62 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { CapitalContract } from 'cooptypes'; +import config from '~/config/config'; +import type { IDelta } from '~/types/common'; +import { ProjectStatus } from '../../domain/enums/project-status.enum'; +import { ProgramShareRegistrationService } from '../services/program-share-registration.service'; + +/** + * Слушатель дельт `capital::projects`. Как только в блокчейне появляется проект + * (в т.ч. компонент) в статусе pending|active — СРАЗУ регистрирует доли всех + * активных пайщиков Благороста в нём, не дожидаясь периодического scheduler'а. + * + * Why: окно между созданием проекта и его переводом в `result` может быть + * минутами. Контракт `regshare` принимает только pending|active, а отката + * `result → active` нет — пайщики, не успевшие попасть до закрытия окна, теряют + * долю в компоненте безвозвратно (инцидент voskhod, компонент 011bcd92…, + * 2026-06-16). Событие закрывает это окно. Зеркалит + * `ProgramShareRegistrationOnUserWalletDeltaListener` (тот реагирует на + * изменение баланса, этот — на появление проекта). + * + * Неблокирующий: обработчик получает событие и неспешно обходит пайщиков; + * на dispatch-pipeline/парсер не влияет (EventEmitter2.emit — fire-and-forget). + * Промахи (downtime/потеря события) подбирает периодический scheduler-бэкстоп. + */ +@Injectable() +export class ProgramShareRegistrationOnProjectDeltaListener { + private readonly logger = new Logger(ProgramShareRegistrationOnProjectDeltaListener.name); + + constructor( + private readonly programShareRegistrationService: ProgramShareRegistrationService + ) {} + + @OnEvent(`delta::${CapitalContract.contractName.production}::${CapitalContract.Tables.Projects.tableName}`) + async handleProjectDelta(delta: IDelta): Promise { + if (!delta.present) return; + if (delta.scope !== config.coopname) return; + + const value = delta.value as CapitalContract.Tables.Projects.IProject | undefined; + if (!value?.project_hash) return; + + // regshare разрешён только для pending|active — на прочих статусах окно уже + // закрыто (on-chain-проверка всё равно отклонит), реагировать незачем. + const status = String(value.status); + if (status !== ProjectStatus.PENDING && status !== ProjectStatus.ACTIVE) return; + + const project_hash = String(value.project_hash); + try { + await this.programShareRegistrationService.syncProgramSharesForProject( + delta.scope, + project_hash + ); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + this.logger.warn( + `regshare event-driven (project) sync не выполнен: coop=${delta.scope} project=${project_hash}: ${message}`, + stack + ); + } + } +} diff --git a/components/controller/src/extensions/capital/application/services/program-share-registration.service.ts b/components/controller/src/extensions/capital/application/services/program-share-registration.service.ts index 60aab6ade40..e50f405cb9a 100644 --- a/components/controller/src/extensions/capital/application/services/program-share-registration.service.ts +++ b/components/controller/src/extensions/capital/application/services/program-share-registration.service.ts @@ -62,8 +62,9 @@ export class ProgramShareRegistrationService { (c.status === ContributorStatus.ACTIVE || c.status === ContributorStatus.IMPORT) ); + const projectHashes = projects.map((p) => p.project_hash); for (const contributor of contributors) { - await this.syncContributor(coopname, contributor, projects); + await this.syncContributor(coopname, contributor, projectHashes); } } @@ -84,7 +85,33 @@ export class ProgramShareRegistrationService { ); if (!contributor) return; - await this.syncContributor(coopname, contributor, projects); + await this.syncContributor(coopname, contributor, projects.map((p) => p.project_hash)); + } + + /** + * Точечная регистрация долей всех активных пайщиков в один проект — вызывается + * из listener'а на дельты `capital::projects` сразу при появлении проекта. + * + * Why: между созданием проекта и его переводом в `result` может пройти меньше + * минуты; контракт `regshare` принимает только статусы pending|active, а откат + * `result → active` не предусмотрен — значит пайщики, не успевшие попасть в + * проект до закрытия окна, теряют долю в нём безвозвратно (инцидент voskhod, + * компонент 011bcd92…, 2026-06-16). Реакция на событие закрывает окно, не + * дожидаясь периодического scheduler'а. + * + * Переиспользует тот же `syncContributor`, что и обход по расписанию. + */ + async syncProgramSharesForProject(coopname: string, project_hash: string): Promise { + const contributors = (await this.contributorRepository.findAll()).filter( + (c) => + c.coopname === coopname && + (c.status === ContributorStatus.ACTIVE || c.status === ContributorStatus.IMPORT) + ); + if (contributors.length === 0) return; + + for (const contributor of contributors) { + await this.syncContributor(coopname, contributor, [project_hash]); + } } private async findActiveProjects(coopname: string): Promise { @@ -98,7 +125,7 @@ export class ProgramShareRegistrationService { private async syncContributor( coopname: string, contributor: ContributorDomainEntity, - projects: ProjectDomainEntity[] + projectHashes: string[] ): Promise { const programId = getProgramId(ProgramType.BLAGOROST); @@ -124,10 +151,10 @@ export class ProgramShareRegistrationService { const targetParsed = AssetUtils.parseAsset(targetShares); if (!targetParsed.symbol) return; - for (const project of projects) { + for (const projectHash of projectHashes) { const segment = await this.capitalBlockchainPort.getSegmentByProjectUser( coopname, - project.project_hash, + projectHash, contributor.username ); @@ -139,19 +166,19 @@ export class ProgramShareRegistrationService { try { await this.capitalBlockchainPort.registerShare({ coopname, - project_hash: project.project_hash, + project_hash: projectHash, username: contributor.username, user_shares: targetShares, }); this.logger.log( - `regshare: ${contributor.username} → проект ${project.project_hash}, user_shares=${targetShares} (было ${registeredStr})` + `regshare: ${contributor.username} → проект ${projectHash}, user_shares=${targetShares} (было ${registeredStr})` ); await delay(REGSHARE_TX_GAP_MS); } catch (error: unknown) { const message = error instanceof HttpApiError ? error.message : error instanceof Error ? error.message : String(error); const stack = error instanceof Error ? error.stack : undefined; this.logger.warn( - `regshare не выполнен: coop=${coopname} project=${project.project_hash} user=${contributor.username}: ${message}`, + `regshare не выполнен: coop=${coopname} project=${projectHash} user=${contributor.username}: ${message}`, stack ); } diff --git a/components/controller/src/extensions/capital/capital-extension.module.ts b/components/controller/src/extensions/capital/capital-extension.module.ts index 8882f124825..72c9988dcf4 100644 --- a/components/controller/src/extensions/capital/capital-extension.module.ts +++ b/components/controller/src/extensions/capital/capital-extension.module.ts @@ -311,6 +311,7 @@ import { GitHubSyncSchedulerService } from './infrastructure/services/github-syn import { ProgramShareRegistrationSchedulerService } from './infrastructure/services/program-share-registration-scheduler.service'; import { ProgramShareRegistrationService } from './application/services/program-share-registration.service'; import { ProgramShareRegistrationOnUserWalletDeltaListener } from './application/listeners/program-share-registration-on-user-wallet-delta.listener'; +import { ProgramShareRegistrationOnProjectDeltaListener } from './application/listeners/program-share-registration-on-project-delta.listener'; import { CapitalDevelopmentRepositoryGitSyncService } from './application/services/capital-development-repository-git-sync.service'; import { CapitalGithubExtensionLifecycleListener } from './application/listeners/capital-github-extension-lifecycle.listener'; import { GitCommitMarkersSyncService } from './application/services/git-commit-markers-sync.service'; @@ -875,6 +876,7 @@ IssueIdGenerationService, ProgramShareRegistrationService, ProgramShareRegistrationSchedulerService, ProgramShareRegistrationOnUserWalletDeltaListener, + ProgramShareRegistrationOnProjectDeltaListener, GitCommitMarkersSyncService, { provide: ISSUE_LINKED_GIT_COMMIT_REPOSITORY, diff --git a/components/controller/src/extensions/capital/infrastructure/services/program-share-registration-scheduler.service.ts b/components/controller/src/extensions/capital/infrastructure/services/program-share-registration-scheduler.service.ts index 019b39f9fb9..07a1d34a12e 100644 --- a/components/controller/src/extensions/capital/infrastructure/services/program-share-registration-scheduler.service.ts +++ b/components/controller/src/extensions/capital/infrastructure/services/program-share-registration-scheduler.service.ts @@ -3,8 +3,15 @@ import { config } from '~/config'; import { ProgramShareRegistrationService } from '../../application/services/program-share-registration.service'; /** - * Периодическая синхронизация долей участников (regshare) по балансу программы Благорост. + * Периодическая сверка долей участников (regshare) с балансом программы Благорост. * Интервал задаётся в конфигурации расширения Capital (минуты); 0 — отключено. + * + * Роль — reconciliation-бэкстоп, не основной путь. Горячие сценарии покрыты + * событиями: появление проекта — `ProgramShareRegistrationOnProjectDeltaListener`, + * изменение баланса — `ProgramShareRegistrationOnUserWalletDeltaListener`. Крон + * оставлен, потому что он ещё и ДОобновляет уже зарегистрированные доли при + * дрейфе баланса (контракт `upsert_contributor_segment` это допускает) и + * подбирает события, потерянные при downtime контроллера. */ @Injectable() export class ProgramShareRegistrationSchedulerService implements OnModuleDestroy { diff --git a/components/controller/tests/unit/capital/program-share-registration-on-project-delta.listener.test.ts b/components/controller/tests/unit/capital/program-share-registration-on-project-delta.listener.test.ts new file mode 100644 index 00000000000..b73cf8f2be4 --- /dev/null +++ b/components/controller/tests/unit/capital/program-share-registration-on-project-delta.listener.test.ts @@ -0,0 +1,68 @@ +/** + * Unit-тесты ProgramShareRegistrationOnProjectDeltaListener. + * + * Фокус — гейтинг: доли регистрируются СРАЗУ при появлении проекта в статусе + * pending|active, и только в своём кооперативе. На present=false, чужой scope и + * статусы вне pending|active реакции нет (regshare там всё равно отклонится). + */ + +import config from '~/config/config'; +import { ProgramShareRegistrationOnProjectDeltaListener } from '~/extensions/capital/application/listeners/program-share-registration-on-project-delta.listener'; +import { ProjectStatus } from '~/extensions/capital/domain/enums/project-status.enum'; +import type { IDelta } from '~/types/common'; + +const PROJECT_HASH = '011bcd92cc6fc6c3fbac1aa31e5ab302590993fedb666642a5bd2f88e96e6a0e'; + +function makeServiceStub() { + return { syncProgramSharesForProject: jest.fn(async () => undefined) } as any; +} + +function makeListener(service: any) { + return new ProgramShareRegistrationOnProjectDeltaListener(service); +} + +function makeDelta(overrides: Partial = {}, value: Record = {}): IDelta { + return { + present: true, + scope: config.coopname, + value: { project_hash: PROJECT_HASH, status: ProjectStatus.PENDING, ...value }, + ...overrides, + } as IDelta; +} + +describe('ProgramShareRegistrationOnProjectDeltaListener', () => { + it('pending в своём кооперативе → регистрирует доли по project_hash', async () => { + const service = makeServiceStub(); + await makeListener(service).handleProjectDelta(makeDelta()); + expect(service.syncProgramSharesForProject).toHaveBeenCalledWith(config.coopname, PROJECT_HASH); + }); + + it('active → тоже регистрирует', async () => { + const service = makeServiceStub(); + await makeListener(service).handleProjectDelta(makeDelta({}, { status: ProjectStatus.ACTIVE })); + expect(service.syncProgramSharesForProject).toHaveBeenCalledTimes(1); + }); + + it('result → не реагирует (окно закрыто, откат статуса контракт не даёт)', async () => { + const service = makeServiceStub(); + await makeListener(service).handleProjectDelta(makeDelta({}, { status: ProjectStatus.RESULT })); + expect(service.syncProgramSharesForProject).not.toHaveBeenCalled(); + }); + + it('present=false → не реагирует', async () => { + const service = makeServiceStub(); + await makeListener(service).handleProjectDelta(makeDelta({ present: false })); + expect(service.syncProgramSharesForProject).not.toHaveBeenCalled(); + }); + + it('чужой кооператив (scope) → не реагирует', async () => { + const service = makeServiceStub(); + await makeListener(service).handleProjectDelta(makeDelta({ scope: 'othercoop' })); + expect(service.syncProgramSharesForProject).not.toHaveBeenCalled(); + }); + + it('ошибка сервиса не пробрасывается (best-effort, бэкстоп — scheduler)', async () => { + const service = { syncProgramSharesForProject: jest.fn(async () => { throw new Error('boom'); }) } as any; + await expect(makeListener(service).handleProjectDelta(makeDelta())).resolves.toBeUndefined(); + }); +}); -- 2.52.0 From 3d04f76446f7a41beb9b0b782bb87f0e8b2199d0 Mon Sep 17 00:00:00 2001 From: coopops Date: Tue, 16 Jun 2026 08:04:08 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(capital):=20=D0=B7=D0=B0=D0=B2=D0=BE?= =?UTF-8?q?=D0=B4=D0=B8=D1=82=D1=8C=20=D0=B4=D0=BE=D0=BB=D0=B8=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B2=20active-=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B5=D0=BA=D1=82=D0=B0=D1=85,=20=D0=BD=D0=B5=20=D0=B2?= =?UTF-8?q?=20pending?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По решению пользователя заход долей Благороста — только в активные проекты. В pending больше не заводим: при переходе pending→active придёт новая дельта capital::projects со status=active, на ней листенер и зарегистрирует. - Листенер ProgramShareRegistrationOnProjectDeltaListener: гейт pending|active → только active. - ProgramShareRegistrationService.findActiveProjects (путь крона и wallet-листенера): фильтр pending|active → только active. - Тест: pending теперь НЕ реагирует; active — позитивный кейс. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...hare-registration-on-project-delta.listener.ts | 15 +++++++++------ .../program-share-registration.service.ts | 14 ++++++++------ ...registration-on-project-delta.listener.test.ts | 14 +++++++------- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/components/controller/src/extensions/capital/application/listeners/program-share-registration-on-project-delta.listener.ts b/components/controller/src/extensions/capital/application/listeners/program-share-registration-on-project-delta.listener.ts index 531745c85b6..9bf58d4feee 100644 --- a/components/controller/src/extensions/capital/application/listeners/program-share-registration-on-project-delta.listener.ts +++ b/components/controller/src/extensions/capital/application/listeners/program-share-registration-on-project-delta.listener.ts @@ -7,9 +7,11 @@ import { ProjectStatus } from '../../domain/enums/project-status.enum'; import { ProgramShareRegistrationService } from '../services/program-share-registration.service'; /** - * Слушатель дельт `capital::projects`. Как только в блокчейне появляется проект - * (в т.ч. компонент) в статусе pending|active — СРАЗУ регистрирует доли всех - * активных пайщиков Благороста в нём, не дожидаясь периодического scheduler'а. + * Слушатель дельт `capital::projects`. Как только проект (в т.ч. компонент) + * оказывается в статусе active — СРАЗУ регистрирует доли всех активных пайщиков + * Благороста в нём, не дожидаясь периодического scheduler'а. В pending не + * заводим: заход только в активные проекты (решение пользователя 2026-06-16); + * при переходе pending→active придёт новая дельта со status=active. * * Why: окно между созданием проекта и его переводом в `result` может быть * минутами. Контракт `regshare` принимает только pending|active, а отката @@ -39,10 +41,11 @@ export class ProgramShareRegistrationOnProjectDeltaListener { const value = delta.value as CapitalContract.Tables.Projects.IProject | undefined; if (!value?.project_hash) return; - // regshare разрешён только для pending|active — на прочих статусах окно уже - // закрыто (on-chain-проверка всё равно отклонит), реагировать незачем. + // Заводим доли только в active-проектах: pending ещё не готов к заходу + // (решение пользователя 2026-06-16). При переходе pending→active придёт + // новая дельта со status=active — на ней и зайдём. const status = String(value.status); - if (status !== ProjectStatus.PENDING && status !== ProjectStatus.ACTIVE) return; + if (status !== ProjectStatus.ACTIVE) return; const project_hash = String(value.project_hash); try { diff --git a/components/controller/src/extensions/capital/application/services/program-share-registration.service.ts b/components/controller/src/extensions/capital/application/services/program-share-registration.service.ts index e50f405cb9a..83f6fed1638 100644 --- a/components/controller/src/extensions/capital/application/services/program-share-registration.service.ts +++ b/components/controller/src/extensions/capital/application/services/program-share-registration.service.ts @@ -47,12 +47,12 @@ export class ProgramShareRegistrationService { ) {} /** - * Обход участников в статусах active/import, проектов pending/active; при изменении user_shares относительно capital_contributor_shares — regshare. + * Обход участников в статусах active/import по active-проектам; при изменении user_shares относительно capital_contributor_shares — regshare. */ async syncProgramSharesForCoop(coopname: string): Promise { const projects = await this.findActiveProjects(coopname); if (projects.length === 0) { - this.logger.debug(`Синхронизация regshare: нет проектов в статусах pending/active для ${coopname}`); + this.logger.debug(`Синхронизация regshare: нет active-проектов для ${coopname}`); return; } @@ -71,7 +71,7 @@ export class ProgramShareRegistrationService { /** * Точечная синхронизация regshare для одного пайщика — вызывается из listener'а * на дельты `ledger2::userwallets[w.cap.blago]`. Не пишет лог, если у пайщика - * нет ни одного pending/active проекта. + * нет ни одного active-проекта. */ async syncProgramSharesForUser(coopname: string, username: string): Promise { const projects = await this.findActiveProjects(coopname); @@ -114,11 +114,13 @@ export class ProgramShareRegistrationService { } } + /** + * Только active-проекты: заход долей в pending отключён (решение пользователя + * 2026-06-16) — заводим/сверяем доли лишь в активных проектах. + */ private async findActiveProjects(coopname: string): Promise { return (await this.projectRepository.findAll()).filter( - (p) => - p.coopname === coopname && - (p.status === ProjectStatus.PENDING || p.status === ProjectStatus.ACTIVE) + (p) => p.coopname === coopname && p.status === ProjectStatus.ACTIVE ); } diff --git a/components/controller/tests/unit/capital/program-share-registration-on-project-delta.listener.test.ts b/components/controller/tests/unit/capital/program-share-registration-on-project-delta.listener.test.ts index b73cf8f2be4..2e4a7871aea 100644 --- a/components/controller/tests/unit/capital/program-share-registration-on-project-delta.listener.test.ts +++ b/components/controller/tests/unit/capital/program-share-registration-on-project-delta.listener.test.ts @@ -2,8 +2,8 @@ * Unit-тесты ProgramShareRegistrationOnProjectDeltaListener. * * Фокус — гейтинг: доли регистрируются СРАЗУ при появлении проекта в статусе - * pending|active, и только в своём кооперативе. На present=false, чужой scope и - * статусы вне pending|active реакции нет (regshare там всё равно отклонится). + * active, и только в своём кооперативе. На pending (заводим только в active), + * present=false, чужой scope и прочие статусы реакции нет. */ import config from '~/config/config'; @@ -25,22 +25,22 @@ function makeDelta(overrides: Partial = {}, value: Record = return { present: true, scope: config.coopname, - value: { project_hash: PROJECT_HASH, status: ProjectStatus.PENDING, ...value }, + value: { project_hash: PROJECT_HASH, status: ProjectStatus.ACTIVE, ...value }, ...overrides, } as IDelta; } describe('ProgramShareRegistrationOnProjectDeltaListener', () => { - it('pending в своём кооперативе → регистрирует доли по project_hash', async () => { + it('active в своём кооперативе → регистрирует доли по project_hash', async () => { const service = makeServiceStub(); await makeListener(service).handleProjectDelta(makeDelta()); expect(service.syncProgramSharesForProject).toHaveBeenCalledWith(config.coopname, PROJECT_HASH); }); - it('active → тоже регистрирует', async () => { + it('pending → не реагирует (заводим только в active, при переходе в active придёт новая дельта)', async () => { const service = makeServiceStub(); - await makeListener(service).handleProjectDelta(makeDelta({}, { status: ProjectStatus.ACTIVE })); - expect(service.syncProgramSharesForProject).toHaveBeenCalledTimes(1); + await makeListener(service).handleProjectDelta(makeDelta({}, { status: ProjectStatus.PENDING })); + expect(service.syncProgramSharesForProject).not.toHaveBeenCalled(); }); it('result → не реагирует (окно закрыто, откат статуса контракт не даёт)', async () => { -- 2.52.0