IMK QA 2건(BB9A 카드 중복·8AB0 중간값 오계산)의 근본 원인이 전술 하드코딩(_TACTICS 번호 매칭)이라 전술을 데이터로 옮기고, 발동을 유효성 검사로 바꿨다. 전술 정본 = 카드 스크립트의 마지막 가격 변수(파싱), 문장으로 알 수 없는 운영 규칙(closing·min_round)만 card.*.tactic JSONB. 세션 시작 시 card_specs 스냅샷 박제. 발동 유효성(하나라도 걸리면 그 라운드 미발동 — 클램프 폐지): 목표가 초과 / 협력사 제시가 이상 / 당사 직전 제안 미만(역행 금지, IMK 논의) / 재료 결측 / 필수 변수 결측(시장가 카드 requires — 토큰 노출 방지) / 이미 쓴 카드(played_card_numbers 공용 이력) - agent: 와일드=비종결·종결=전용 풀 분리(같은 카드 2회 구조적 차단), 발동 시 자기 제안가 기록(절충가 수렴), 진입 존 프로브(빈 덱 재사용 교착 방지), 낼 카드 전무 시 소진→종결, 무효 금액 카드는 설득 폴백도 금지(playable), 에디터 anchor_price 별칭 등록, 에러 재렌더 변수 치환 - backend: 카드 사용 기록을 step 휴리스틱→번호 prefix 판정(종결 발동 card:null 누락 해소) - negodata: 카드 상세 "협상 전술" 섹션(제시 가격 파싱 표시·종결 전용·최소 라운드) + tactic API 배선 - postgres-init: tactic 컬럼·시드(WC-03/05 closing, WC-04 min_round 2), 멱등 alter 로 dev 정본화 (번호 WC-0x 정규화, WC-01·03·NGC-010 구멘트 교체, WC-05 변수 middle_price 교정) 검증: agent 178 통과 · 시나리오 하네스 14케이스(BB9A·8AB0·역행 실수치 재현) · 랜덤 퍼즈 50협상 불변식 위반 0 (불변식: 카드 중복 금지·종결 카드 자리·타결가≤목표가·표시가=타결가·토큰 잔존 금지·종료 보장)
242 lines
10 KiB
Python
242 lines
10 KiB
Python
"""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("_base")
|
|
e2 = await reg.get_engine("imarketkorea")
|
|
assert e1 is not e2
|
|
assert e1.tenant_id == "_base" and e2.tenant_id == "imarketkorea"
|
|
# 서로 다른 카드매핑 (다른 카드셋 — _base=공용 카탈로그, imk=파일 오버라이드)
|
|
assert e1.mapper.get_card_id(0) == "NGC-001"
|
|
assert e2.mapper.get_card_id(0) == "NGC-B001"
|
|
# 차원
|
|
assert e1.state_space_size == 162 and e1.action_space_size == 9 # 카탈로그 9장(NGC-006·009 소프트삭제)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_engine_cached():
|
|
reg = _registry()
|
|
a = await reg.get_engine("imarketkorea")
|
|
b = await reg.get_engine("imarketkorea")
|
|
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("imarketkorea") 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 == 9 and eng.state_space_size == 162 # DB 카탈로그 9장
|
|
assert eng.company_id == "00000000-0000-0000-0000-000000000001"
|
|
assert reg.is_registered("imarketkorea") 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("_base")
|
|
b = await reg.get_engine("imarketkorea")
|
|
reloaded = await reg.reload("_base")
|
|
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, company_id=None):
|
|
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, company_id=None):
|
|
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("imarketkorea") # 비-UUID → 조회 안 함
|
|
assert eng.config.resources.company_name == "데모상사 B"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_catalog_includes_company_cards(db_engine):
|
|
"""per-company 카탈로그: 공용 카드(NGC-*) + 그 회사 유저가 만든 카드 — 공용 먼저 → 회사 뒤."""
|
|
import uuid as _uuid
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import column, delete, insert, table
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
from common.enums import DBType, ErrorType
|
|
from negotiation.cards.adapters.card_catalog_db import CardCatalogDbRepository
|
|
|
|
cid, uid, card_id = _uuid.uuid4(), _uuid.uuid4(), _uuid.uuid4()
|
|
_USERS = table("users", column("user_id"), column("company_id"), column("id"), column("password"),
|
|
column("last_accessed_at"), column("status"), column("role"), schema="company")
|
|
_NEGO = table("nego_cards", column("nego_card_id"), column("user_id"), column("name"),
|
|
column("number"), column("usage_type"), schema="card")
|
|
|
|
async def _seed(s):
|
|
await DB_SESSION_MNG.add(s, insert(_USERS).values(
|
|
user_id=uid, company_id=cid, id="pytest_catalog_user", password="x",
|
|
last_accessed_at=datetime.now(timezone.utc), status=1, role=1))
|
|
return await DB_SESSION_MNG.add(s, insert(_NEGO).values(
|
|
nego_card_id=card_id, user_id=uid, name="회사전용카드", number="COMP-01", usage_type=1))
|
|
|
|
err = await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [_seed])
|
|
assert err == ErrorType.SUCCESS
|
|
try:
|
|
repo = CardCatalogDbRepository()
|
|
_, nums = await DB_SESSION_MNG.execute_lambda(
|
|
DBType.MAIN.value, 1, lambda s: repo.get_nego_catalog(s, cid))
|
|
assert "COMP-01" in nums # 회사 카드 포함
|
|
assert nums[0] == "NGC-001" # 공용이 먼저(action_id 0 안정)
|
|
assert nums[-1] == "COMP-01" # 회사 카드는 뒤에 append
|
|
assert nums.index("NGC-011") < nums.index("COMP-01") # 공용 전부 → 회사
|
|
|
|
# company_id 없으면 공용만 (회사 카드 제외)
|
|
_, shared_only = await DB_SESSION_MNG.execute_lambda(
|
|
DBType.MAIN.value, 1, lambda s: repo.get_nego_catalog(s, None))
|
|
assert "COMP-01" not in shared_only
|
|
finally:
|
|
await DB_SESSION_MNG.execute_lambda_run(
|
|
[DBType.MAIN.value],
|
|
[lambda s: DB_SESSION_MNG.add(s, delete(_NEGO).where(_NEGO.c.nego_card_id == card_id)),
|
|
lambda s: DB_SESSION_MNG.add(s, delete(_USERS).where(_USERS.c.user_id == uid))],
|
|
)
|
|
|
|
|
|
@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": "imarketkorea"})
|
|
assert r.status_code == 404
|
|
assert r.json().get("result", {}).get("desc") != "TENANT_NOT_REGISTERED"
|