[598-51][@ant] fix: починить unit-тесты marketplace после замены whitelist на реестр поставщиков — конструкторы сервисов набрали новые обязательные параметры при мерже PR #152, моки не обновили и тесты не компилировались; добавлен spec для MarketplaceSupplierRegistryService взамен удалённого теста несуществующего сервиса
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+9
@@ -123,6 +123,13 @@ function buildMocks() {
|
||||
resolvePayoutMethod: jest.fn().mockResolvedValue(null),
|
||||
} as any;
|
||||
|
||||
// Реквизиты договора поставщика для назначения платежа в initiatePayouts
|
||||
// (см. MarketplaceSupplierRegistryService.findByMember). По умолчанию —
|
||||
// «нет записи»: buildPayoutPurpose падает на fallback-текст без реквизитов.
|
||||
const supplierRegistry = {
|
||||
findByMember: jest.fn().mockResolvedValue(null),
|
||||
} as any;
|
||||
|
||||
const supplierActionService = {
|
||||
declineOrdersAtReception: jest.fn().mockResolvedValue([]),
|
||||
} as any;
|
||||
@@ -147,6 +154,7 @@ function buildMocks() {
|
||||
inventoryRepo,
|
||||
coreGateway,
|
||||
supplierSettings,
|
||||
supplierRegistry,
|
||||
supplierActionService,
|
||||
documentDomainService,
|
||||
logger,
|
||||
@@ -166,6 +174,7 @@ function buildService(mocks: ReturnType<typeof buildMocks>): MarketplaceAplRecep
|
||||
{ symbol: 'RUB', decimals: 4 },
|
||||
mocks.coreGateway,
|
||||
mocks.supplierSettings,
|
||||
mocks.supplierRegistry,
|
||||
mocks.supplierActionService,
|
||||
mocks.documentDomainService,
|
||||
new EventEmitter2(),
|
||||
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Unit-тесты MarketplaceSupplierRegistryService.
|
||||
*
|
||||
* Заменяет удалённый MarketplaceWhitelistService (PR #152, [598-51]) — тот
|
||||
* тестировал упразднённую концепцию whitelist (открытая витрина / по списку),
|
||||
* которой в коде больше нет. Реестр поставщиков — другая модель: заявка →
|
||||
* рассмотрение → APPROVED/REJECTED, допуск офферера строго по статусу.
|
||||
*
|
||||
* Покрывают:
|
||||
* - isOfferer: сам кооператив всегда true (без похода в репозиторий),
|
||||
* APPROVED → true, PENDING/REJECTED/нет записи → false, cache hit;
|
||||
* - requestMembership: создание PENDING, идемпотентность повторной заявки,
|
||||
* конфликт на уже APPROVED, переоткрытие REJECTED с новыми реквизитами;
|
||||
* - addSupplier: upsert (create при отсутствии / patch при наличии) сразу в APPROVED;
|
||||
* - approve/reject: патчат статус, инвалидируют isOfferer-кеш;
|
||||
* - switchModel: SHARE — заглушка (ConflictException), MEMBERSHIP — сброс в PENDING.
|
||||
*/
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { MarketplaceSupplierRegistryService } from './marketplace-supplier-registry.service';
|
||||
import { MarketplaceSupplierDomainEntity } from '../../domain/entities/marketplace-supplier.entity';
|
||||
import {
|
||||
MarketplaceSupplierModel,
|
||||
MarketplaceSupplierStatus,
|
||||
} from '../../domain/entities/marketplace-supplier.types';
|
||||
import type { MarketplaceSupplierDomainRepository } from '../../domain/repositories/marketplace-supplier.repository';
|
||||
|
||||
const COOP = 'voskhod';
|
||||
|
||||
function makeSupplier(
|
||||
member: string,
|
||||
status: MarketplaceSupplierStatus,
|
||||
overrides: Partial<{
|
||||
model: MarketplaceSupplierModel;
|
||||
contract_number: string | null;
|
||||
contract_date: string | null;
|
||||
reviewed_by: string | null;
|
||||
reviewed_at: Date | null;
|
||||
}> = {}
|
||||
): MarketplaceSupplierDomainEntity {
|
||||
return new MarketplaceSupplierDomainEntity({
|
||||
id: `id-${member}`,
|
||||
coopname: COOP,
|
||||
member_account: member,
|
||||
model: overrides.model ?? MarketplaceSupplierModel.MEMBERSHIP,
|
||||
status,
|
||||
contract_number: overrides.contract_number ?? '17/2026',
|
||||
contract_date: overrides.contract_date ?? '2026-05-01',
|
||||
contract_document_url: null,
|
||||
requested_by: member,
|
||||
requested_at: new Date('2026-05-01T00:00:00Z'),
|
||||
reviewed_by: overrides.reviewed_by ?? null,
|
||||
reviewed_at: overrides.reviewed_at ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function makeRepo(): jest.Mocked<MarketplaceSupplierDomainRepository> {
|
||||
return {
|
||||
list: jest.fn(),
|
||||
findByMember: jest.fn(),
|
||||
create: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeEventBus() {
|
||||
return { emit: jest.fn() } as unknown as import('@nestjs/event-emitter').EventEmitter2;
|
||||
}
|
||||
|
||||
describe('MarketplaceSupplierRegistryService', () => {
|
||||
describe('isOfferer', () => {
|
||||
it('сам кооператив → true без обращения к репозиторию (перепоставка остатков FR5)', async () => {
|
||||
const repo = makeRepo();
|
||||
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
|
||||
|
||||
await expect(service.isOfferer(COOP, COOP)).resolves.toBe(true);
|
||||
expect(repo.findByMember).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('запись APPROVED → true', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.findByMember.mockResolvedValue(makeSupplier('alice', MarketplaceSupplierStatus.APPROVED));
|
||||
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
|
||||
|
||||
await expect(service.isOfferer(COOP, 'alice')).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('запись PENDING → false (ещё не одобрен)', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.findByMember.mockResolvedValue(makeSupplier('alice', MarketplaceSupplierStatus.PENDING));
|
||||
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
|
||||
|
||||
await expect(service.isOfferer(COOP, 'alice')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('записи нет → false', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.findByMember.mockResolvedValue(null);
|
||||
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
|
||||
|
||||
await expect(service.isOfferer(COOP, 'ghost')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('второй вызов того же пайщика — cache hit, repo не дёргается повторно', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.findByMember.mockResolvedValue(makeSupplier('alice', MarketplaceSupplierStatus.APPROVED));
|
||||
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
|
||||
|
||||
await service.isOfferer(COOP, 'alice');
|
||||
await service.isOfferer(COOP, 'alice');
|
||||
expect(repo.findByMember).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestMembership', () => {
|
||||
it('без существующей записи → создаёт PENDING и эмитит уведомление председателю', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.findByMember.mockResolvedValue(null);
|
||||
repo.create.mockResolvedValue(makeSupplier('bob', MarketplaceSupplierStatus.PENDING));
|
||||
const eventBus = makeEventBus();
|
||||
const service = new MarketplaceSupplierRegistryService(repo, eventBus);
|
||||
|
||||
const result = await service.requestMembership(COOP, 'bob', '17/2026', '2026-05-01');
|
||||
|
||||
expect(result.status).toBe(MarketplaceSupplierStatus.PENDING);
|
||||
expect(repo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
coopname: COOP,
|
||||
member_account: 'bob',
|
||||
status: MarketplaceSupplierStatus.PENDING,
|
||||
contract_number: '17/2026',
|
||||
contract_date: '2026-05-01',
|
||||
})
|
||||
);
|
||||
expect(eventBus.emit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('уже поданная PENDING-заявка → идемпотентна, возвращает существующую без повторной записи', async () => {
|
||||
const repo = makeRepo();
|
||||
const pending = makeSupplier('bob', MarketplaceSupplierStatus.PENDING);
|
||||
repo.findByMember.mockResolvedValue(pending);
|
||||
const eventBus = makeEventBus();
|
||||
const service = new MarketplaceSupplierRegistryService(repo, eventBus);
|
||||
|
||||
const result = await service.requestMembership(COOP, 'bob', '17/2026', '2026-05-01');
|
||||
|
||||
expect(result).toBe(pending);
|
||||
expect(repo.create).not.toHaveBeenCalled();
|
||||
expect(repo.patch).not.toHaveBeenCalled();
|
||||
expect(eventBus.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('уже APPROVED → 409 Conflict, повторная заявка запрещена', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.findByMember.mockResolvedValue(makeSupplier('bob', MarketplaceSupplierStatus.APPROVED));
|
||||
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
|
||||
|
||||
await expect(service.requestMembership(COOP, 'bob', '17/2026', '2026-05-01')).rejects.toThrow(
|
||||
ConflictException
|
||||
);
|
||||
expect(repo.patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('REJECTED → переоткрывает заявку в PENDING с новыми реквизитами договора', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.findByMember.mockResolvedValue(makeSupplier('bob', MarketplaceSupplierStatus.REJECTED));
|
||||
repo.patch.mockResolvedValue(
|
||||
makeSupplier('bob', MarketplaceSupplierStatus.PENDING, {
|
||||
contract_number: '18/2026',
|
||||
contract_date: '2026-06-01',
|
||||
})
|
||||
);
|
||||
const eventBus = makeEventBus();
|
||||
const service = new MarketplaceSupplierRegistryService(repo, eventBus);
|
||||
|
||||
const result = await service.requestMembership(COOP, 'bob', '18/2026', '2026-06-01');
|
||||
|
||||
expect(repo.patch).toHaveBeenCalledWith(
|
||||
COOP,
|
||||
'bob',
|
||||
expect.objectContaining({
|
||||
status: MarketplaceSupplierStatus.PENDING,
|
||||
contract_number: '18/2026',
|
||||
contract_date: '2026-06-01',
|
||||
reviewed_by: null,
|
||||
reviewed_at: null,
|
||||
})
|
||||
);
|
||||
expect(result.contract_number).toBe('18/2026');
|
||||
expect(eventBus.emit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addSupplier (путь 2 — прямое добавление администратором)', () => {
|
||||
it('без существующей записи → создаёт сразу APPROVED', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.findByMember.mockResolvedValue(null);
|
||||
repo.create.mockResolvedValue(makeSupplier('carl', MarketplaceSupplierStatus.APPROVED));
|
||||
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
|
||||
|
||||
const result = await service.addSupplier(
|
||||
COOP,
|
||||
'carl',
|
||||
MarketplaceSupplierModel.MEMBERSHIP,
|
||||
'19/2026',
|
||||
'2026-06-10',
|
||||
'chairman1'
|
||||
);
|
||||
|
||||
expect(result.status).toBe(MarketplaceSupplierStatus.APPROVED);
|
||||
expect(repo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: MarketplaceSupplierStatus.APPROVED, requested_by: 'chairman1', reviewed_by: 'chairman1' })
|
||||
);
|
||||
expect(repo.patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('с существующей записью → upsert через patch в APPROVED', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.findByMember.mockResolvedValue(makeSupplier('carl', MarketplaceSupplierStatus.REJECTED));
|
||||
repo.patch.mockResolvedValue(makeSupplier('carl', MarketplaceSupplierStatus.APPROVED));
|
||||
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
|
||||
|
||||
await service.addSupplier(COOP, 'carl', MarketplaceSupplierModel.MEMBERSHIP, '20/2026', '2026-06-11', 'chairman1');
|
||||
|
||||
expect(repo.patch).toHaveBeenCalledWith(
|
||||
COOP,
|
||||
'carl',
|
||||
expect.objectContaining({ status: MarketplaceSupplierStatus.APPROVED, reviewed_by: 'chairman1' })
|
||||
);
|
||||
expect(repo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('инвалидирует isOfferer-кеш кооператива', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.findByMember.mockResolvedValueOnce(null); // прогрев кеша isOfferer('dave') → false
|
||||
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
|
||||
await expect(service.isOfferer(COOP, 'dave')).resolves.toBe(false);
|
||||
|
||||
repo.findByMember.mockResolvedValueOnce(null); // addSupplier сам ищет существующую запись
|
||||
repo.create.mockResolvedValue(makeSupplier('dave', MarketplaceSupplierStatus.APPROVED));
|
||||
await service.addSupplier(COOP, 'dave', MarketplaceSupplierModel.MEMBERSHIP, '21/2026', '2026-06-12', 'chairman1');
|
||||
|
||||
repo.findByMember.mockResolvedValueOnce(makeSupplier('dave', MarketplaceSupplierStatus.APPROVED));
|
||||
await expect(service.isOfferer(COOP, 'dave')).resolves.toBe(true);
|
||||
// Без инвалидации второй вызов взял бы старое значение из кеша (false) без похода в repo.
|
||||
expect(repo.findByMember).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('approve / reject', () => {
|
||||
it('approve патчит статус в APPROVED', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.patch.mockResolvedValue(makeSupplier('eve', MarketplaceSupplierStatus.APPROVED));
|
||||
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
|
||||
|
||||
const result = await service.approve(COOP, 'eve', 'chairman1');
|
||||
|
||||
expect(result.status).toBe(MarketplaceSupplierStatus.APPROVED);
|
||||
expect(repo.patch).toHaveBeenCalledWith(
|
||||
COOP,
|
||||
'eve',
|
||||
expect.objectContaining({ status: MarketplaceSupplierStatus.APPROVED, reviewed_by: 'chairman1' })
|
||||
);
|
||||
});
|
||||
|
||||
it('reject патчит статус в REJECTED', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.patch.mockResolvedValue(makeSupplier('eve', MarketplaceSupplierStatus.REJECTED));
|
||||
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
|
||||
|
||||
const result = await service.reject(COOP, 'eve', 'chairman1');
|
||||
|
||||
expect(result.status).toBe(MarketplaceSupplierStatus.REJECTED);
|
||||
expect(repo.patch).toHaveBeenCalledWith(
|
||||
COOP,
|
||||
'eve',
|
||||
expect.objectContaining({ status: MarketplaceSupplierStatus.REJECTED, reviewed_by: 'chairman1' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('switchModel', () => {
|
||||
it('SHARE — заглушка, 409 Conflict, репозиторий не трогается', async () => {
|
||||
const repo = makeRepo();
|
||||
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
|
||||
|
||||
await expect(
|
||||
service.switchModel(COOP, 'alice', MarketplaceSupplierModel.SHARE, null, null)
|
||||
).rejects.toThrow(ConflictException);
|
||||
expect(repo.patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('MEMBERSHIP — сбрасывает запись в PENDING с новым договором', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.patch.mockResolvedValue(
|
||||
makeSupplier('alice', MarketplaceSupplierStatus.PENDING, {
|
||||
model: MarketplaceSupplierModel.MEMBERSHIP,
|
||||
})
|
||||
);
|
||||
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
|
||||
|
||||
const result = await service.switchModel(
|
||||
COOP,
|
||||
'alice',
|
||||
MarketplaceSupplierModel.MEMBERSHIP,
|
||||
'22/2026',
|
||||
'2026-06-15'
|
||||
);
|
||||
|
||||
expect(result.status).toBe(MarketplaceSupplierStatus.PENDING);
|
||||
expect(repo.patch).toHaveBeenCalledWith(
|
||||
COOP,
|
||||
'alice',
|
||||
expect.objectContaining({
|
||||
model: MarketplaceSupplierModel.MEMBERSHIP,
|
||||
status: MarketplaceSupplierStatus.PENDING,
|
||||
contract_number: '22/2026',
|
||||
contract_date: '2026-06-15',
|
||||
reviewed_by: null,
|
||||
reviewed_at: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+26
-1
@@ -87,6 +87,17 @@ function buildMocks() {
|
||||
applyStatusTransition: jest.fn(),
|
||||
} as unknown as jest.Mocked<MarketplaceInventoryDomainRepository>;
|
||||
|
||||
// Обогащение позиций списания названием товара/заказа и адресом КУ
|
||||
// (см. MarketplaceOrderDisplayService.resolveBranchDisplay) — по умолчанию
|
||||
// «нет данных», тесты бизнес-логики списания эти поля не проверяют.
|
||||
const orderRepo = { findById: jest.fn().mockResolvedValue(null) } as any;
|
||||
const offerRepo = { findById: jest.fn().mockResolvedValue(null) } as any;
|
||||
const orderDisplay = {
|
||||
resolveBranchDisplay: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ name: null, address: null, lat: null, lng: null }),
|
||||
} as any;
|
||||
|
||||
const chainPort: jest.Mocked<MarketplaceCanonicalBlockchainPort> = {
|
||||
propWroff: jest.fn().mockResolvedValue({ transaction: { id: 'tx-prop' } } as any),
|
||||
execWroff: jest.fn().mockResolvedValue({ transaction: { id: 'tx-exec' } } as any),
|
||||
@@ -105,16 +116,30 @@ function buildMocks() {
|
||||
error: jest.fn(),
|
||||
} as any;
|
||||
|
||||
return { repo, inventoryRepo, chainPort, assetConfig, documentDomainService, eventBus, logger };
|
||||
return {
|
||||
repo,
|
||||
inventoryRepo,
|
||||
orderRepo,
|
||||
offerRepo,
|
||||
chainPort,
|
||||
assetConfig,
|
||||
documentDomainService,
|
||||
orderDisplay,
|
||||
eventBus,
|
||||
logger,
|
||||
};
|
||||
}
|
||||
|
||||
function buildService(mocks: ReturnType<typeof buildMocks>): MarketplaceWriteoffService {
|
||||
return new MarketplaceWriteoffService(
|
||||
mocks.repo,
|
||||
mocks.inventoryRepo,
|
||||
mocks.orderRepo,
|
||||
mocks.offerRepo,
|
||||
mocks.chainPort,
|
||||
mocks.assetConfig,
|
||||
mocks.documentDomainService,
|
||||
mocks.orderDisplay,
|
||||
mocks.eventBus,
|
||||
mocks.logger
|
||||
);
|
||||
|
||||
+9
-1
@@ -24,10 +24,18 @@ const makeResolver = (ownBranames: string[]) => {
|
||||
const kuChairmanService = {
|
||||
listBranamesForMember: jest.fn().mockResolvedValue(ownBranames),
|
||||
} as any;
|
||||
// Обогащение результата ФИО заказчиков и реквизитами КУ (см. resolver:
|
||||
// marketplaceListInventory зовёт их сразу после inventoryRepo.list) —
|
||||
// тесты скоупинга их содержимое не проверяют, только факт вызова list().
|
||||
const orderDisplay = {
|
||||
resolveAccountNames: jest.fn().mockResolvedValue(new Map()),
|
||||
enrichByOrderIds: jest.fn().mockResolvedValue(new Map()),
|
||||
} as any;
|
||||
const resolver = new MarketplaceInventoryResolver(
|
||||
labelService,
|
||||
inventoryRepo,
|
||||
kuChairmanService
|
||||
kuChairmanService,
|
||||
orderDisplay
|
||||
);
|
||||
return { resolver, inventoryRepo, kuChairmanService };
|
||||
};
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Unit-тесты MarketplaceMembershipGuard.
|
||||
*
|
||||
* Story 1.3 (auth/active/server-secret) + Story 3.1 (isOfferer source — whitelist).
|
||||
* Story 1.3 (auth/active/server-secret) + isOfferer source — реестр поставщиков
|
||||
* (MarketplaceSupplierRegistryService, заменил whitelist в PR #152).
|
||||
*
|
||||
* Покрывают AC:
|
||||
* (a) пайщик с user.role='user', status=active, whitelist пуст → guard
|
||||
* пропускает, marketplace_roles=['orderer','offerer'] (open vitrine);
|
||||
* (b) пайщик с user.role='user', whitelist непустой и пайщика там нет →
|
||||
* (a) пайщик с user.role='user', status=active, isOfferer=true → guard
|
||||
* пропускает, marketplace_roles=['orderer','offerer'];
|
||||
* (b) пайщик с user.role='user', isOfferer=false →
|
||||
* marketplace_roles=['orderer'] (без offerer);
|
||||
* (c) member и chairman дают расширенный core_roles + marketplace_roles;
|
||||
* (d) status != active → ForbiddenException (HTTP 403);
|
||||
@@ -17,7 +18,7 @@ import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
import { MarketplaceMembershipGuard } from '~/extensions/marketplace/application/guards/marketplace-membership.guard';
|
||||
import type { MarketplaceKuChairmanService } from '~/extensions/marketplace/application/services/marketplace-ku-chairman.service';
|
||||
import type { MarketplaceWhitelistService } from '~/extensions/marketplace/application/services/marketplace-whitelist.service';
|
||||
import type { MarketplaceSupplierRegistryService } from '~/extensions/marketplace/application/services/marketplace-supplier-registry.service';
|
||||
|
||||
jest.mock('~/config/config', () => ({
|
||||
__esModule: true,
|
||||
@@ -42,10 +43,10 @@ function makeCtx(req: any) {
|
||||
};
|
||||
}
|
||||
|
||||
function makeWhitelistService(isOffererResult: boolean): MarketplaceWhitelistService {
|
||||
function makeSupplierRegistryService(isOffererResult: boolean): MarketplaceSupplierRegistryService {
|
||||
return {
|
||||
isOfferer: jest.fn().mockResolvedValue(isOffererResult),
|
||||
} as unknown as MarketplaceWhitelistService;
|
||||
} as unknown as MarketplaceSupplierRegistryService;
|
||||
}
|
||||
|
||||
function makeKuChairmanService(isKuChairmanResult: boolean): MarketplaceKuChairmanService {
|
||||
@@ -55,8 +56,8 @@ function makeKuChairmanService(isKuChairmanResult: boolean): MarketplaceKuChairm
|
||||
}
|
||||
|
||||
describe('MarketplaceMembershipGuard', () => {
|
||||
it('user.role=user, status=active, whitelist пуст → ctx [User] + marketplace_roles [orderer, offerer]', async () => {
|
||||
const guard = new MarketplaceMembershipGuard(makeWhitelistService(true), makeKuChairmanService(false));
|
||||
it('user.role=user, status=active, isOfferer=true → ctx [User] + marketplace_roles [orderer, offerer]', async () => {
|
||||
const guard = new MarketplaceMembershipGuard(makeSupplierRegistryService(true), makeKuChairmanService(false));
|
||||
const req = { user: { username: 'alice', role: 'user', status: 'active' }, headers: {} };
|
||||
const ctx = makeCtx(req);
|
||||
|
||||
@@ -69,8 +70,8 @@ describe('MarketplaceMembershipGuard', () => {
|
||||
expect(req).toHaveProperty('currentMember');
|
||||
});
|
||||
|
||||
it('user.role=user, whitelist непустой и пайщика там нет → marketplace_roles [orderer] (без offerer)', async () => {
|
||||
const guard = new MarketplaceMembershipGuard(makeWhitelistService(false), makeKuChairmanService(false));
|
||||
it('user.role=user, isOfferer=false → marketplace_roles [orderer] (без offerer)', async () => {
|
||||
const guard = new MarketplaceMembershipGuard(makeSupplierRegistryService(false), makeKuChairmanService(false));
|
||||
const req = { user: { username: 'alice', role: 'user', status: 'active' }, headers: {} };
|
||||
const ctx = makeCtx(req);
|
||||
|
||||
@@ -79,7 +80,7 @@ describe('MarketplaceMembershipGuard', () => {
|
||||
});
|
||||
|
||||
it('user.role=member, status=active → core_roles [User, Member] + marketplace_roles [orderer, offerer, board_readonly]', async () => {
|
||||
const guard = new MarketplaceMembershipGuard(makeWhitelistService(true), makeKuChairmanService(false));
|
||||
const guard = new MarketplaceMembershipGuard(makeSupplierRegistryService(true), makeKuChairmanService(false));
|
||||
const req = { user: { username: 'bob', role: 'member', status: 'active' }, headers: {} };
|
||||
const ctx = makeCtx(req);
|
||||
await expect(guard.canActivate(ctx as any)).resolves.toBe(true);
|
||||
@@ -92,7 +93,7 @@ describe('MarketplaceMembershipGuard', () => {
|
||||
});
|
||||
|
||||
it('user.role=chairman, status=active → marketplace_roles полный набор', async () => {
|
||||
const guard = new MarketplaceMembershipGuard(makeWhitelistService(true), makeKuChairmanService(false));
|
||||
const guard = new MarketplaceMembershipGuard(makeSupplierRegistryService(true), makeKuChairmanService(false));
|
||||
const req = { user: { username: 'chair', role: 'chairman', status: 'active' }, headers: {} };
|
||||
const ctx = makeCtx(req);
|
||||
await expect(guard.canActivate(ctx as any)).resolves.toBe(true);
|
||||
@@ -107,7 +108,7 @@ describe('MarketplaceMembershipGuard', () => {
|
||||
});
|
||||
|
||||
it('status != active → 403 Forbidden «Доступ только для пайщиков кооператива»', async () => {
|
||||
const guard = new MarketplaceMembershipGuard(makeWhitelistService(false), makeKuChairmanService(false));
|
||||
const guard = new MarketplaceMembershipGuard(makeSupplierRegistryService(false), makeKuChairmanService(false));
|
||||
const req = { user: { username: 'alice', role: 'user', status: '4_Registered' }, headers: {} };
|
||||
const ctx = makeCtx(req);
|
||||
await expect(guard.canActivate(ctx as any)).rejects.toThrow(ForbiddenException);
|
||||
@@ -117,14 +118,14 @@ describe('MarketplaceMembershipGuard', () => {
|
||||
});
|
||||
|
||||
it('нет user (нет JWT) → 401 Unauthorized', async () => {
|
||||
const guard = new MarketplaceMembershipGuard(makeWhitelistService(false), makeKuChairmanService(false));
|
||||
const guard = new MarketplaceMembershipGuard(makeSupplierRegistryService(false), makeKuChairmanService(false));
|
||||
const req = { headers: {} };
|
||||
const ctx = makeCtx(req);
|
||||
await expect(guard.canActivate(ctx as any)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('server-secret bypass → true, currentMember не выставляется, whitelist/branches не дёргаются', async () => {
|
||||
const ws = makeWhitelistService(true);
|
||||
it('server-secret bypass → true, currentMember не выставляется, supplierRegistry/kuChairman не дёргаются', async () => {
|
||||
const ws = makeSupplierRegistryService(true);
|
||||
const ku = makeKuChairmanService(false);
|
||||
const guard = new MarketplaceMembershipGuard(ws, ku);
|
||||
const req = { headers: { 'server-secret': 'test-secret' } };
|
||||
@@ -136,7 +137,7 @@ describe('MarketplaceMembershipGuard', () => {
|
||||
});
|
||||
|
||||
it('isKuChairman=true → marketplace_roles содержит operator', async () => {
|
||||
const guard = new MarketplaceMembershipGuard(makeWhitelistService(false), makeKuChairmanService(true));
|
||||
const guard = new MarketplaceMembershipGuard(makeSupplierRegistryService(false), makeKuChairmanService(true));
|
||||
const req = { user: { username: 'chairkrg', role: 'user', status: 'active' }, headers: {} };
|
||||
const ctx = makeCtx(req);
|
||||
await expect(guard.canActivate(ctx as any)).resolves.toBe(true);
|
||||
|
||||
@@ -43,6 +43,8 @@ function makeOffer(overrides: Partial<MarketplaceOfferDomainEntity> = {}): Marke
|
||||
warranty_days: 0,
|
||||
barcode_strategy: 'PER_ORDER',
|
||||
pack_size: null,
|
||||
stock_braname: null,
|
||||
stock_origin_offer_id: null,
|
||||
images: [],
|
||||
status: 'PENDING_MODERATION',
|
||||
approved_by: null,
|
||||
|
||||
+2
@@ -43,6 +43,8 @@ function makeOffer(overrides: Partial<MarketplaceOfferDomainEntity> = {}): Marke
|
||||
warranty_days: 0,
|
||||
barcode_strategy: 'PER_ORDER',
|
||||
pack_size: null,
|
||||
stock_braname: null,
|
||||
stock_origin_offer_id: null,
|
||||
images: [],
|
||||
status: 'ACTIVE',
|
||||
approved_by: 'chair',
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
/**
|
||||
* Unit-тесты MarketplaceWhitelistService (Story 3.1).
|
||||
*
|
||||
* Покрывают AC:
|
||||
* - listWhitelist возвращает все записи (auto-coop + manual);
|
||||
* - addToWhitelist создаёт role='manual', проставляет added_by;
|
||||
* - removeFromWhitelist удаляет manual-запись и инвалидирует кеш;
|
||||
* - removeFromWhitelist на auto-coop → 403 Forbidden (FR5);
|
||||
* - removeFromWhitelist на отсутствующего → 404 NotFound;
|
||||
* - isOfferer семантика «открытая витрина» (whitelist только auto-coop)
|
||||
* → true для всех;
|
||||
* - isOfferer семантика «по whitelist» → true только для записанных;
|
||||
* - isOfferer cache hit без обращения к репозиторию.
|
||||
*/
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { MarketplaceWhitelistService } from '~/extensions/marketplace/application/services/marketplace-whitelist.service';
|
||||
import { MarketplaceWhitelistEntryDomainEntity } from '~/extensions/marketplace/domain/entities/marketplace-whitelist-entry.entity';
|
||||
import type { MarketplaceWhitelistDomainRepository } from '~/extensions/marketplace/domain/repositories/marketplace-whitelist.repository';
|
||||
|
||||
const COOP = 'voskhod';
|
||||
|
||||
function makeEntry(member: string, role: 'auto-coop' | 'manual'): MarketplaceWhitelistEntryDomainEntity {
|
||||
return new MarketplaceWhitelistEntryDomainEntity({
|
||||
id: `id-${member}`,
|
||||
coopname: COOP,
|
||||
member_account: member,
|
||||
role,
|
||||
added_by: role === 'auto-coop' ? null : 'admin',
|
||||
added_at: new Date('2026-05-15T12:00:00Z'),
|
||||
});
|
||||
}
|
||||
|
||||
function makeRepo(): jest.Mocked<MarketplaceWhitelistDomainRepository> {
|
||||
return {
|
||||
list: jest.fn(),
|
||||
findByMember: jest.fn(),
|
||||
add: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
countManual: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('MarketplaceWhitelistService', () => {
|
||||
it('list возвращает все записи', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.list.mockResolvedValue([makeEntry(COOP, 'auto-coop'), makeEntry('alice', 'manual')]);
|
||||
const service = new MarketplaceWhitelistService(repo);
|
||||
|
||||
const result = await service.list(COOP);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((e) => e.member_account)).toEqual([COOP, 'alice']);
|
||||
});
|
||||
|
||||
it('addToWhitelist создаёт manual-запись с added_by', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.add.mockResolvedValue(makeEntry('bob', 'manual'));
|
||||
const service = new MarketplaceWhitelistService(repo);
|
||||
|
||||
const entry = await service.addToWhitelist(COOP, 'bob', 'admin');
|
||||
expect(entry.member_account).toBe('bob');
|
||||
expect(entry.role).toBe('manual');
|
||||
expect(repo.add).toHaveBeenCalledWith(COOP, 'bob', 'manual', 'admin');
|
||||
});
|
||||
|
||||
it('removeFromWhitelist удаляет manual-запись', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.findByMember.mockResolvedValue(makeEntry('bob', 'manual'));
|
||||
const service = new MarketplaceWhitelistService(repo);
|
||||
|
||||
await service.removeFromWhitelist(COOP, 'bob');
|
||||
expect(repo.remove).toHaveBeenCalledWith(COOP, 'bob');
|
||||
});
|
||||
|
||||
it('removeFromWhitelist на auto-coop → 403 (FR5)', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.findByMember.mockResolvedValue(makeEntry(COOP, 'auto-coop'));
|
||||
const service = new MarketplaceWhitelistService(repo);
|
||||
|
||||
await expect(service.removeFromWhitelist(COOP, COOP)).rejects.toThrow(ForbiddenException);
|
||||
expect(repo.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removeFromWhitelist на отсутствующего → 404 NotFound', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.findByMember.mockResolvedValue(null);
|
||||
const service = new MarketplaceWhitelistService(repo);
|
||||
|
||||
await expect(service.removeFromWhitelist(COOP, 'ghost')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('isOfferer: открытая витрина (только auto-coop) → true для всех', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.countManual.mockResolvedValue(0);
|
||||
const service = new MarketplaceWhitelistService(repo);
|
||||
|
||||
await expect(service.isOfferer(COOP, 'random_user')).resolves.toBe(true);
|
||||
expect(repo.findByMember).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('isOfferer: whitelist непустой, пайщик внутри → true', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.countManual.mockResolvedValue(2);
|
||||
repo.findByMember.mockResolvedValue(makeEntry('alice', 'manual'));
|
||||
const service = new MarketplaceWhitelistService(repo);
|
||||
|
||||
await expect(service.isOfferer(COOP, 'alice')).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('isOfferer: whitelist непустой, пайщик снаружи → false', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.countManual.mockResolvedValue(2);
|
||||
repo.findByMember.mockResolvedValue(null);
|
||||
const service = new MarketplaceWhitelistService(repo);
|
||||
|
||||
await expect(service.isOfferer(COOP, 'random_user')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('isOfferer: второй вызов того же пайщика — cache hit, repo не дёргается', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.countManual.mockResolvedValue(0);
|
||||
const service = new MarketplaceWhitelistService(repo);
|
||||
|
||||
await service.isOfferer(COOP, 'alice');
|
||||
await service.isOfferer(COOP, 'alice');
|
||||
expect(repo.countManual).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('addToWhitelist инвалидирует кеш isOfferer', async () => {
|
||||
const repo = makeRepo();
|
||||
repo.countManual.mockResolvedValueOnce(0);
|
||||
repo.add.mockResolvedValue(makeEntry('alice', 'manual'));
|
||||
repo.countManual.mockResolvedValueOnce(1);
|
||||
repo.findByMember.mockResolvedValue(null);
|
||||
const service = new MarketplaceWhitelistService(repo);
|
||||
|
||||
// Прогрев кеша: открытая витрина → true
|
||||
await expect(service.isOfferer(COOP, 'random_user')).resolves.toBe(true);
|
||||
// Добавление manual-записи (alice) → инвалидирует кеш
|
||||
await service.addToWhitelist(COOP, 'alice', 'admin');
|
||||
// После инвалидации семантика «по whitelist» → false для random_user
|
||||
await expect(service.isOfferer(COOP, 'random_user')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('removeFromWhitelist инвалидирует кеш isOfferer', async () => {
|
||||
const repo = makeRepo();
|
||||
// Прогрев isOfferer: countManual=1 + findByMember(random_user)=null → false.
|
||||
// Затем remove('alice'): findByMember(alice)=manual-entry, repo.remove() ok.
|
||||
// Второй isOfferer: countManual=0 → семантика «открытая витрина» → true,
|
||||
// findByMember не дёргается (короткое замыкание).
|
||||
repo.countManual.mockResolvedValueOnce(1).mockResolvedValueOnce(0);
|
||||
repo.findByMember
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(makeEntry('alice', 'manual'));
|
||||
const service = new MarketplaceWhitelistService(repo);
|
||||
|
||||
await expect(service.isOfferer(COOP, 'random_user')).resolves.toBe(false);
|
||||
await service.removeFromWhitelist(COOP, 'alice');
|
||||
await expect(service.isOfferer(COOP, 'random_user')).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user