[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:
ant
2026-07-21 08:44:18 +00:00
parent 0ff1643633
commit 5dd65c5de7
8 changed files with 392 additions and 181 deletions
@@ -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(),
@@ -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,
})
);
});
});
});
@@ -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
);
@@ -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,
@@ -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);
});
});