"""P4 검증 (계획서 P4 _검증_). 1. 두 테넌트가 서로 다른 엔진/카드매핑(q_table 차원·action) 사용. 2. 엔진 지연생성 + 캐시(동일 테넌트는 동일 인스턴스). 3. 동시 첫 요청에서 lock 으로 1회만 조립 (동시성). 4. 미등록 테넌트 get_engine → KeyError. 5. 미들웨어: 헤더 누락 400, 미등록 404, 등록 테넌트는 통과. 6. episode 상태 외부화: EpisodeState 는 요청 스코프(엔진에 없음). """ import asyncio import os import pytest from negotiation.policies.base import EpisodeState from tenancy.config_loader import TenantConfigLoader from tenancy.registry import EngineFactory, TenantEngine, TenantEngineRegistry _TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants") def _registry() -> TenantEngineRegistry: return TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0)) @pytest.mark.asyncio async def test_two_tenants_distinct_engines(): reg = _registry() e1 = await reg.get_engine("ktcommerce") e2 = await reg.get_engine("imarketkorea") assert e1 is not e2 assert e1.tenant_id == "ktcommerce" and e2.tenant_id == "imarketkorea" # 서로 다른 카드매핑 (다른 카드셋) assert e1.mapper.get_card_id(0) == "NGC-A001" assert e2.mapper.get_card_id(0) == "NGC-B001" # 차원 assert e1.state_space_size == 162 and e1.action_space_size == 11 @pytest.mark.asyncio async def test_engine_cached(): reg = _registry() a = await reg.get_engine("ktcommerce") b = await reg.get_engine("ktcommerce") assert a is b # 캐시 — 동일 인스턴스 @pytest.mark.asyncio async def test_concurrent_first_build_once(): # 조립 횟수 카운트용 팩토리 builds = {"n": 0} class CountingFactory(EngineFactory): @staticmethod def build(config): builds["n"] += 1 return EngineFactory.build(config) reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0), factory=CountingFactory) results = await asyncio.gather(*[reg.get_engine("ktcommerce") for _ in range(12)]) # 모두 같은 인스턴스 + 1회만 조립 assert all(r is results[0] for r in results) assert builds["n"] == 1 @pytest.mark.asyncio async def test_unregistered_company_id_auto_onboards(): reg = _registry() # 미등록 company_id(uuid)는 _base 자동 온보딩 → 엔진 생성됨(베이스 9카드, 162 state). eng = await reg.get_engine("00000000-0000-0000-0000-000000000001") assert eng.action_space_size == 11 and eng.state_space_size == 162 assert eng.company_id == "00000000-0000-0000-0000-000000000001" assert reg.is_registered("ktcommerce") is True # 빈 키만 미등록 → KeyError with pytest.raises(KeyError): await reg.get_engine("") @pytest.mark.asyncio async def test_reload_rebuilds_only_that_tenant(): reg = _registry() a = await reg.get_engine("ktcommerce") b = await reg.get_engine("imarketkorea") reloaded = await reg.reload("ktcommerce") assert reloaded is not a # 재조립됨 assert await reg.get_engine("imarketkorea") is b # 타테넌트는 그대로 def test_episode_state_is_request_scoped(): # 엔진은 episode 상태를 갖지 않는다 — EpisodeState 는 독립 객체. es1 = EpisodeState() es2 = EpisodeState() es1.mark_used(3) assert es1.used_action_ids == {3} assert es2.used_action_ids == set() # 서로 오염 없음 assert not hasattr(TenantEngine, "used_action_ids") @pytest.mark.asyncio async def test_action_space_from_db_catalog(db_engine): """action_mapping.type=db(_base) 면 카드 카탈로그(DB)가 action space 를 정의한다 — config 파일이 아님. negodata 에서 카드가 추가/삭제되면 config 수정 없이 action space 가 반영됨을 의미.""" from common.enums import ErrorType from negotiation.cards.ports.card_catalog_port import ICardCatalogRepository class _FakeCatalog(ICardCatalogRepository): async def get_nego_catalog(self, cdb): return ErrorType.SUCCESS, ["NGC-001", "NGC-002", "NGC-003"] # 3장짜리 카탈로그(파일은 11장) reg = TenantEngineRegistry( loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0), catalog_repo=_FakeCatalog(), ) eng = await reg.get_engine("_base") # _base = type:db assert eng.action_space_size == 3 # DB 카탈로그(3)가 정의 — 파일 폴백(11) 아님 assert eng.mapper.get_card_id(0) == "NGC-001" assert eng.mapper.get_card_id(2) == "NGC-003" assert eng.mapper.get_action_id("NGC-002") == 1 @pytest.mark.asyncio async def test_action_space_falls_back_to_file_when_catalog_empty(db_engine): """카탈로그가 비면(신규/미시드 DB) 파일 action_to_card 로 폴백한다.""" from common.enums import ErrorType from negotiation.cards.ports.card_catalog_port import ICardCatalogRepository class _EmptyCatalog(ICardCatalogRepository): async def get_nego_catalog(self, cdb): return ErrorType.SUCCESS, [] reg = TenantEngineRegistry( loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0), catalog_repo=_EmptyCatalog(), ) eng = await reg.get_engine("_base") assert eng.action_space_size == 11 # 파일 폴백(11장 스냅샷) assert eng.mapper.get_card_id(0) == "NGC-001" @pytest.mark.asyncio async def test_company_brand_from_db_for_auto_onboard(db_engine): """자동 온보딩 고객사(company_id=UUID)는 company.companies.name 으로 {company_name} 을 채운다.""" import uuid as _uuid from common.enums import ErrorType from tenancy.company_profile_repo import ICompanyProfileRepository class _FakeCompany(ICompanyProfileRepository): async def get_company_name(self, cdb, company_id): return ErrorType.SUCCESS, "풀무원" reg = TenantEngineRegistry( loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0), company_repo=_FakeCompany(), ) eng = await reg.get_engine(str(_uuid.uuid4())) # UUID → 자동 온보딩 + 브랜드 DB assert eng.config.resources.company_name == "풀무원" @pytest.mark.asyncio async def test_demo_tenant_keeps_file_brand(db_engine): """데모 테넌트(비-UUID)는 회사명 조회 없이 파일 브랜드 유지.""" from common.enums import ErrorType from tenancy.company_profile_repo import ICompanyProfileRepository class _FakeCompany(ICompanyProfileRepository): async def get_company_name(self, cdb, company_id): return ErrorType.SUCCESS, "USED-ONLY-IF-QUERIED" reg = TenantEngineRegistry( loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0), company_repo=_FakeCompany(), ) eng = await reg.get_engine("ktcommerce") # 비-UUID → 조회 안 함 assert eng.config.resources.company_name == "데모상사 A" @pytest.mark.asyncio async def test_middleware_header_missing_unregistered_registered(client): # 헤더 누락 → 400 r = await client.get("/v1/protected") assert r.status_code == 400 assert r.json()["result"]["desc"] == "TENANT_HEADER_MISSING" # 미등록 company_id(uuid) → 자동 온보딩이라 미들웨어 통과 (라우트 미존재라 404지만 TENANT_NOT_REGISTERED 아님) r = await client.get("/v1/protected", headers={"X-Tenant-ID": "00000000-0000-0000-0000-000000000001"}) assert r.status_code == 404 assert r.json().get("result", {}).get("desc") != "TENANT_NOT_REGISTERED" # 등록 테넌트 → 미들웨어 통과 r = await client.get("/v1/protected", headers={"X-Tenant-ID": "ktcommerce"}) assert r.status_code == 404 assert r.json().get("result", {}).get("desc") != "TENANT_NOT_REGISTERED"