feat(marketplace): Story 1.1 — установка Стола заказов из Каталога приложений (#368)
Publish Docs / build-and-publish-docs (push) Failing after 13m10s

- Подключить MarketplacePluginModule в ExtensionsModule.register (расширение
  не загружалось в NestJS DI-контейнер).
- Унифицировать имя расширения: MarketplacePlugin.name = 'market' (совпадает
  с ключом AppRegistry; install через installExtension({name:"market"}) теперь
  не падает на findByName('marketplace')).
- Расширить MarketplacePlugin.initialize: bucket coop-<coopname> через
  optional file-storage порт (@Optional injection, fallback под warn-логом до
  merge PR #359); три AC-лога backend'а — 'Создан физический бакет ...',
  'File storage готов', 'marketplace-extension готов'.
- Bootstrap config-миграция v1 для расширения market.
- Atomic rollback в ExtensionInteractor.installApp: при провале runApp снести
  запись через uninstallApp, только если её не было до install; ошибка
  пробрасывается наверх (UI Каталога показывает 'Ошибка').
- Process locator: обновить TODO p.mkt.* — заглушки готовы, активация в
  Story 4.1 при создании marketplace::requests.

Unit-тесты:
- tests/unit/marketplace/marketplace-plugin-initialize.test.ts (4 проходят).
- tests/unit/appstore/extension-interactor-install-rollback.test.ts (3 проходят).

Refs: blago/production/1-prilozhenie-stol-zakazov/components/3-minimalnyy-produkt/_bmad-output/implementation-artifacts/spec-1-1-install-from-catalog.md

Co-authored-by: coopops <coopos@coopenomics.world>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Ant
2026-05-14 15:50:51 +05:00
committed by GitHub
parent 467f33ff09
commit ab8c644ef7
8 changed files with 293 additions and 15 deletions
@@ -4,13 +4,17 @@ import { Injectable } from '@nestjs/common';
import { ExtensionDomainService } from '~/domain/extension/services/extension-domain.service';
import { ExtensionDomainEntity } from '~/domain/extension/entities/extension-domain.entity';
import { ExtensionLifecycleDomainService } from '~/domain/extension/services/extension-lifecycle-domain.service';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
@Injectable()
export class ExtensionInteractor<TConfig = any> {
constructor(
private readonly extensionDomainService: ExtensionDomainService<TConfig>,
private readonly appLifecycleService: ExtensionLifecycleDomainService
) {}
private readonly appLifecycleService: ExtensionLifecycleDomainService,
private readonly logger: WinstonLoggerService
) {
this.logger.setContext(ExtensionInteractor.name);
}
async runApps() {
await this.appLifecycleService.runApps();
@@ -45,12 +49,37 @@ export class ExtensionInteractor<TConfig = any> {
return app;
}
// Установка нового приложения
// Установка нового приложения с atomic-rollback при провале runApp.
// Если запись об этом расширении уже существовала до вызова install,
// rollback не сносит её — ограничиваемся пробросом исключения.
async installApp(appData: Partial<ExtensionDomainEntity<TConfig>>): Promise<ExtensionDomainEntity<TConfig>> {
// Установка нового приложения
const preExisting = appData.name
? await this.extensionDomainService.getAppByName(appData.name as string)
: null;
const app = await this.extensionDomainService.installApp(appData);
// Запуск приложения
if (appData.enabled) await this.runApp(app.name);
if (appData.enabled) {
try {
await this.runApp(app.name);
} catch (error) {
this.logger.error(
`[INSTALL_APP] Расширение ${app.name} провалилось на runApp — откатываю установку`,
error instanceof Error ? error.stack : String(error)
);
if (!preExisting) {
try {
await this.uninstallApp({ name: app.name });
} catch (rollbackError) {
this.logger.error(
`[INSTALL_APP] Rollback uninstallApp(${app.name}) тоже провалился`,
rollbackError instanceof Error ? rollbackError.stack : String(rollbackError)
);
}
}
throw error;
}
}
return app;
}
@@ -11,6 +11,7 @@ import { chatcoopManagedMatrixRoomsV2Migration } from '~/extensions/chatcoop/mig
import { chatcoopManagedMatrixRoomsV3Migration } from '~/extensions/chatcoop/migrations/chatcoop-managed-matrix-rooms-v3.migration';
import { chatcoopStatePgV4Migration } from '~/extensions/chatcoop/migrations/chatcoop-state-pg-v4.migration';
import { chatcoopMessageHistoryIngestCursorV5Migration } from '~/extensions/chatcoop/migrations/chatcoop-message-history-ingest-cursor-v5.migration';
import { marketplaceBootstrapV1Migration } from '~/extensions/marketplace/migrations/marketplace-bootstrap-v1.migration';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { ExtensionsModule } from '~/extensions/extensions.module';
@@ -54,6 +55,7 @@ export class ExtensionDomainModule {
this.migrationService.registerMigration(chatcoopManagedMatrixRoomsV3Migration);
this.migrationService.registerMigration(chatcoopStatePgV4Migration);
this.migrationService.registerMigration(chatcoopMessageHistoryIngestCursorV5Migration);
this.migrationService.registerMigration(marketplaceBootstrapV1Migration);
// Устанавливаем расширения по умолчанию
await this.extensionInteractor.installDefaultApps();
@@ -121,10 +121,12 @@ export const PROCESS_HASH_LOCATOR: Readonly<Record<string, HashLocation[]>> = Ob
// текущего состояния (контроллер не получит operation_codes, требующие
// этих locator'ов).
//
// TODO (при реализации контракта marketplace членской модели):
// подставить { code: 'marketplace', table: '<TBD>', field: 'request_hash' }.
// Универсальное имя поля — `request_hash` (а не `order_hash`, чтобы не
// путать с `offer_hash` в orderoffer-actions).
// Status (Story 1.1, 2026-05-14): расширение `market` устанавливается из
// Каталога приложений; ключи `p.mkt.*` в Phase B готовы к активации.
// Реальные `{code, table, field}` подставляются в Story 4.1 при создании
// backend-таблицы консолидированных заявок (Locked Decision L10) — поле
// `request_hash` (а не `order_hash`, чтобы не путать с `offer_hash` в
// orderoffer-actions).
'p.mkt.supply': [],
'p.mkt.return': [],
'p.mkt.wroff': [],
@@ -11,6 +11,7 @@ import { ParticipantPluginModule } from './participant/participant-extension.mod
import { ChatCoopPluginModule } from './chatcoop/chatcoop-extension.module';
import { OneCoopPluginModule } from './1ccoop/oneccoop-extension.module';
import { ReportsExtensionModule } from './reports/reports-extension.module';
import { MarketplacePluginModule } from './marketplace/marketplace-extension.module';
import { ExtensionDomainModule } from '~/domain/extension/extension-domain.module';
import { GatewayDomainModule } from '~/domain/gateway/gateway-domain.module';
@@ -36,6 +37,7 @@ export class ExtensionsModule {
InterCommunicationBridgeModule,
OneCoopPluginModule,
ReportsExtensionModule,
MarketplacePluginModule,
],
providers: [],
// Экспортируем все модули расширений, чтобы их провайдеры были доступны
@@ -53,6 +55,7 @@ export class ExtensionsModule {
InterCommunicationBridgeModule,
OneCoopPluginModule,
ReportsExtensionModule,
MarketplacePluginModule,
],
};
}
@@ -1,4 +1,4 @@
import { Inject, Injectable, Module } from '@nestjs/common';
import { Inject, Injectable, Module, Optional } from '@nestjs/common';
import { BaseExtModule } from '../base.extension.module';
import {
EXTENSION_REPOSITORY,
@@ -7,21 +7,40 @@ import {
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import type { ExtensionDomainEntity } from '~/domain/extension/entities/extension-domain.entity';
import { merge } from 'lodash';
import { config } from '~/config';
import { IConfig, defaultConfig, Schema } from './types';
import { MarketplaceExtensionDomainModule } from './domain/marketplace-domain.module';
import { MarketplaceExtensionApplicationModule } from './application/marketplace-application.module';
/**
* Optional-инжектируемый порт файлового хранилища. Имя расширения marketplace
* подключается к bucket через `@coopenomics/inter` (см. AR31 в epics.md).
* До merge PR #359 `feat(file-storage)` адаптера ещё нет — поэтому через
* `@Optional` и опциональный токен.
*/
export const MARKETPLACE_FILE_STORAGE_PORT = Symbol('MARKETPLACE_FILE_STORAGE_PORT');
export interface IMarketplaceFileStoragePort {
ensureBucket(bucketName: string): Promise<void>;
}
@Injectable()
export class MarketplacePlugin extends BaseExtModule {
constructor(
@Inject(EXTENSION_REPOSITORY) private readonly extensionRepository: ExtensionDomainRepository<IConfig>,
private readonly logger: WinstonLoggerService
private readonly logger: WinstonLoggerService,
@Optional()
@Inject(MARKETPLACE_FILE_STORAGE_PORT)
private readonly fileStorage: IMarketplaceFileStoragePort | null = null
) {
super();
this.logger.setContext(MarketplacePlugin.name);
}
name = 'marketplace';
// Имя в реестре расширений совпадает с ключом AppRegistry['market']
// (extensions.registry.ts). При установке из Каталога приложений именно это
// имя приходит в `installExtension({name: "market", ...})`.
name = 'market';
plugin!: ExtensionDomainEntity<IConfig>;
public configSchemas = Schema;
@@ -31,13 +50,34 @@ export class MarketplacePlugin extends BaseExtModule {
const pluginData = await this.extensionRepository.findByName(this.name);
if (!pluginData) throw new Error('Конфиг не найден');
// Применяем глубокий мердж дефолтных параметров с существующими
this.plugin = {
...pluginData,
config: merge({}, defaultConfig, pluginData.config),
};
this.logger.info(`Инициализация ${this.name} с конфигурацией`, this.plugin.config);
await this.initBucket();
this.logger.info('marketplace-extension готов');
}
/**
* Bucket для хранения изображений Стола заказов и фотографий гарантийного
* возврата. Имя — `coop-<coopname>` (см. AR31). Если адаптер file-storage
* не подключён (PR #359 не вмержен), пропускаем шаг с warn-логом; install
* расширения не падает — оставшиеся шаги выполняются.
*/
private async initBucket(): Promise<void> {
if (!this.fileStorage) {
this.logger.warn(
'File storage не настроен — пропускаем bucket init (PR #359 не вмержен)'
);
return;
}
const bucketName = `coop-${config.coopname}`;
await this.fileStorage.ensureBucket(bucketName);
this.logger.info(`Создан физический бакет '${bucketName}'`);
this.logger.info('File storage готов');
}
}
@@ -0,0 +1,27 @@
import type {
IExtensionSchemaMigration,
ExtensionSchemaMigrationAfterContext,
} from '~/domain/extension/services/extension-schema-migration.service';
import { defaultConfig, IConfig } from '../types';
/**
* Bootstrap-миграция расширения `market` (Стол заказов).
*
* Story 1.1 (epics.md, Эпик 1). Гарантирует, что `extension.config` после
* установки содержит все ключи `defaultConfig`; `currentConfig` сохраняется
* поверх дефолтов — пользовательские переопределения не теряются. Фаза
* `afterMigrate` — no-op: data-миграции на этапе MVP-bootstrap не нужны
* (entity-таблицы marketplace::request* появятся в Эпике 4).
*/
export const marketplaceBootstrapV1Migration: IExtensionSchemaMigration<Partial<IConfig>, IConfig> = {
extensionName: 'market',
version: 1,
migrate(oldConfig, def) {
return { ...def, ...oldConfig };
},
async afterMigrate(_ctx: ExtensionSchemaMigrationAfterContext) {
// no-op для Story 1.1
},
};
@@ -0,0 +1,102 @@
/**
* Unit-тесты ExtensionInteractor.installApp atomic rollback (Story 1.1).
*
* AC «при провале установки — статус «Ошибка», логи backend содержат
* подробности, процессы и БД-таблицы откатываются».
*
* Покрытие:
* (a) runApp бросил, записи в БД не было до install → uninstallApp вызван;
* (b) runApp бросил, запись уже существовала → uninstallApp НЕ вызван
* (rollback ограничивается пробросом исключения, чтобы не снести
* чужую установку);
* (c) runApp прошёл → uninstallApp НЕ вызван, возвращается app.
*
* Прямой import `ExtensionInteractor` тянет через
* `ExtensionLifecycleDomainService → extensions.registry → capital-extension`
* и упирается в ESM `@octokit/rest` (та же причина, что и в
* `capital-plugin-register.test.ts` — комментарий «Тест чистой функции — не
* тянет за собой импорт CapitalPlugin»). Mock'аем lifecycle-service на пустышку
* — interactor использует его только как тип конструктора + два метода
* (`runApp`, `terminateApp`), которые мы передаём явно.
*/
jest.mock('~/domain/extension/services/extension-lifecycle-domain.service', () => ({
ExtensionLifecycleDomainService: class {},
}));
import { ExtensionInteractor } from '~/application/appstore/interactors/extension.interactor';
const makeLogger = () =>
({
setContext: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
} as any);
describe('ExtensionInteractor.installApp atomic rollback', () => {
it('rollback-ит uninstall если runApp провалился и записи не было до install', async () => {
const installed = { name: 'market', enabled: true, config: {} };
const extensionDomainService = {
getAppByName: jest.fn().mockResolvedValue(null),
installApp: jest.fn().mockResolvedValue(installed),
uninstallApp: jest.fn().mockResolvedValue(true),
} as any;
const appLifecycleService = {
runApp: jest.fn().mockRejectedValue(new Error('init failed')),
terminateApp: jest.fn().mockResolvedValue(undefined),
} as any;
const logger = makeLogger();
const interactor = new ExtensionInteractor(extensionDomainService, appLifecycleService, logger);
await expect(
interactor.installApp({ name: 'market', enabled: true } as any)
).rejects.toThrow('init failed');
expect(extensionDomainService.uninstallApp).toHaveBeenCalledWith({ name: 'market' });
expect(logger.error).toHaveBeenCalled();
});
it('НЕ удаляет если запись существовала до install (rollback = только throw)', async () => {
const preExisting = { name: 'market', enabled: true, config: {} };
const extensionDomainService = {
getAppByName: jest.fn().mockResolvedValue(preExisting),
installApp: jest.fn().mockResolvedValue(preExisting),
uninstallApp: jest.fn().mockResolvedValue(true),
} as any;
const appLifecycleService = {
runApp: jest.fn().mockRejectedValue(new Error('init failed')),
terminateApp: jest.fn().mockResolvedValue(undefined),
} as any;
const logger = makeLogger();
const interactor = new ExtensionInteractor(extensionDomainService, appLifecycleService, logger);
await expect(
interactor.installApp({ name: 'market', enabled: true } as any)
).rejects.toThrow('init failed');
expect(extensionDomainService.uninstallApp).not.toHaveBeenCalled();
});
it('возвращает app при успешном runApp', async () => {
const installed = { name: 'market', enabled: true, config: {} };
const extensionDomainService = {
getAppByName: jest.fn().mockResolvedValue(null),
installApp: jest.fn().mockResolvedValue(installed),
uninstallApp: jest.fn(),
} as any;
const appLifecycleService = {
runApp: jest.fn().mockResolvedValue(undefined),
terminateApp: jest.fn().mockResolvedValue(undefined),
} as any;
const logger = makeLogger();
const interactor = new ExtensionInteractor(extensionDomainService, appLifecycleService, logger);
const result = await interactor.installApp({ name: 'market', enabled: true } as any);
expect(result).toBe(installed);
expect(appLifecycleService.runApp).toHaveBeenCalledWith('market');
expect(extensionDomainService.uninstallApp).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,73 @@
/**
* Unit-тесты MarketplacePlugin.initialize (Story 1.1).
*
* Покрывают AC-логи:
* (a) file-storage порт не подключён → warn-лог про PR #359, остальные шаги
* выполняются, итоговый «marketplace-extension готов» в логе есть;
* (b) file-storage порт подключён → ensureBucket(`coop-<coopname>`) вызван,
* три AC-лога: «Создан физический бакет ...», «File storage готов»,
* «marketplace-extension готов»;
* (c) в БД нет записи `market` → initialize бросает «Конфиг не найден».
*/
import { MarketplacePlugin } from '~/extensions/marketplace/marketplace-extension.module';
const makeLogger = () =>
({
setContext: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
} as any);
const makeRepo = (record: any = { name: 'market', config: {}, enabled: true }) =>
({
findByName: jest.fn().mockResolvedValue(record),
} as any);
describe('MarketplacePlugin.initialize', () => {
it('пишет warn о fallback и продолжает install, если file-storage не подключён', async () => {
const logger = makeLogger();
const repo = makeRepo();
const plugin = new MarketplacePlugin(repo, logger, null);
await plugin.initialize();
expect(logger.warn).toHaveBeenCalledWith(
'File storage не настроен — пропускаем bucket init (PR #359 не вмержен)'
);
expect(logger.info).toHaveBeenCalledWith('marketplace-extension готов');
expect(logger.info).not.toHaveBeenCalledWith(
expect.stringContaining('Создан физический бакет')
);
});
it('вызывает ensureBucket и пишет все три AC-лога когда file-storage подключён', async () => {
const logger = makeLogger();
const repo = makeRepo();
const fileStorage = { ensureBucket: jest.fn().mockResolvedValue(undefined) };
const plugin = new MarketplacePlugin(repo, logger, fileStorage);
await plugin.initialize();
expect(fileStorage.ensureBucket).toHaveBeenCalledTimes(1);
expect(fileStorage.ensureBucket.mock.calls[0][0]).toMatch(/^coop-/);
expect(logger.info).toHaveBeenCalledWith(expect.stringMatching(/Создан физический бакет 'coop-/));
expect(logger.info).toHaveBeenCalledWith('File storage готов');
expect(logger.info).toHaveBeenCalledWith('marketplace-extension готов');
});
it('бросает «Конфиг не найден» если в БД нет записи market', async () => {
const logger = makeLogger();
const repo = makeRepo(null);
const plugin = new MarketplacePlugin(repo, logger, null);
await expect(plugin.initialize()).rejects.toThrow('Конфиг не найден');
});
it('расширение зарегистрировано под именем `market` (совпадает с ключом AppRegistry)', () => {
const plugin = new MarketplacePlugin(makeRepo(), makeLogger(), null);
expect(plugin.name).toBe('market');
});
});