feat(marketplace): Story 1.1 — установка Стола заказов из Каталога приложений (#368)
Publish Docs / build-and-publish-docs (push) Failing after 13m10s
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:
@@ -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 готов');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
@@ -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
|
||||
},
|
||||
};
|
||||
+102
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user