백엔드 close_and_decide 경로: - 동시 이중 마감 가드: 마감 판정 전 원자적 CLOSED 선점(claim)으로 두 크론 잡·수동마감 경합 직렬화 - 재생성 실패 표면화: regenerate_next_round 결과 검사 → 실패 시 REGEN_FAILED 반환(체인 끊김 은폐 방지) - 차수 충돌 방지: 다음 라운드 = 체인 최신 round+1(chain_max_round 기준) - 재생성 사유 집계 정밀화: 미참여/동가를 양성 표식으로 구분(단독낙찰·거부 오집계 제거) - 재생성 라운드 최소 협상기간 하한(즉시 재마감 캐스케이드 방지) - 잡 루프 per-item 예외 격리(한 건 실패가 배치 전체를 멈추지 않음) 프론트: - useChatController: 무권한 가드를 sessionId 별로 추적해 세션 변경 시 자연 해제 - useScrollLock: 마지막 해제를 rAF 로 지연해 재마운트 사이 일시적 잠금 해제 방지 - quotation 상세 쿼리 placeholderData 로 라운드 전환 중 시트 유지 테스트: - 신규 test_close_and_decide_fixes.py(동시성·차수·집계·기간 하한 검증) - conftest 결함 수정(존재하지 않는 tbl_account TRUNCATE 제거, companies.status 명시) - stale 테스트 갱신(test_quotation_create 를 타입드 Req/신규 응답 형식에 맞게 재작성) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
89 lines
3.2 KiB
Python
89 lines
3.2 KiB
Python
"""item 도메인 e2e — CRUD + company 멀티테넌트 스코프 검증.
|
|
|
|
실행 전제: PostgreSQL(negodata_db). docker compose up -d 후 python -m pytest.
|
|
"""
|
|
import uuid
|
|
|
|
import pytest_asyncio
|
|
from sqlalchemy import text
|
|
|
|
from common.enums import CompanyStatus
|
|
|
|
|
|
async def _headers(client, company_id, login_id="itemuser", pw="pw1234"):
|
|
await client.post(
|
|
"/v1/auth/create",
|
|
json={"id": login_id, "password": pw, "company_id": company_id, "name": "n"},
|
|
)
|
|
r = await client.post("/v1/auth/login", json={"id": login_id, "password": pw})
|
|
return {"Authorization": f"Bearer {r.json()['access_token']}"}
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def other_company_id(db_engine) -> str:
|
|
cid = uuid.uuid4()
|
|
async with db_engine.begin() as conn:
|
|
# status 는 NOT NULL(모델 default 는 ORM 전용이라 raw INSERT 엔 안 먹음) → 명시.
|
|
await conn.execute(
|
|
text("INSERT INTO companies (company_id, name, status) VALUES (:cid, :name, :status)"),
|
|
{"cid": cid, "name": "다른회사", "status": CompanyStatus.ACTIVE.value},
|
|
)
|
|
return str(cid)
|
|
|
|
|
|
async def test_item_crud_flow(client, company_id):
|
|
h = await _headers(client, company_id)
|
|
|
|
# 등록
|
|
r = await client.post("/v1/item/create", json={"name": "상품A", "price": 1000, "code": "C1"}, headers=h)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["result"]["success"] is True
|
|
item_id = body["item"]["item_id"]
|
|
assert body["item"]["name"] == "상품A"
|
|
|
|
# 목록
|
|
r = await client.get("/v1/item/list", headers=h)
|
|
body = r.json()
|
|
assert body["total"] == 1 and len(body["items"]) == 1
|
|
|
|
# 단건 조회
|
|
r = await client.get(f"/v1/item/{item_id}", headers=h)
|
|
assert r.json()["item"]["item_id"] == item_id
|
|
|
|
# 수정 (부분)
|
|
r = await client.patch(f"/v1/item/update/{item_id}", json={"price": 2000}, headers=h)
|
|
assert r.json()["item"]["price"] == 2000
|
|
assert r.json()["item"]["name"] == "상품A" # 미지정 필드 유지
|
|
|
|
# 삭제 (soft)
|
|
r = await client.delete(f"/v1/item/delete/{item_id}", headers=h)
|
|
assert r.json()["result"]["success"] is True
|
|
|
|
# 삭제 후 목록 0
|
|
r = await client.get("/v1/item/list", headers=h)
|
|
assert r.json()["total"] == 0
|
|
|
|
|
|
async def test_item_not_found(client, company_id):
|
|
h = await _headers(client, company_id)
|
|
r = await client.get(f"/v1/item/{uuid.uuid4()}", headers=h)
|
|
body = r.json()
|
|
assert body["result"]["success"] is False
|
|
assert body["result"]["code"] == 1300 # ITEM_NOT_FOUND
|
|
|
|
|
|
async def test_item_company_scope(client, company_id, other_company_id):
|
|
# 회사 A 가 상품 등록
|
|
ha = await _headers(client, company_id, login_id="userA")
|
|
r = await client.post("/v1/item/create", json={"name": "A상품"}, headers=ha)
|
|
a_item_id = r.json()["item"]["item_id"]
|
|
|
|
# 회사 B 유저는 A 의 상품을 목록/단건에서 볼 수 없다
|
|
hb = await _headers(client, other_company_id, login_id="userB")
|
|
r = await client.get("/v1/item/list", headers=hb)
|
|
assert r.json()["total"] == 0
|
|
|
|
r = await client.get(f"/v1/item/{a_item_id}", headers=hb)
|
|
assert r.json()["result"]["code"] == 1300 # 타사 자원은 ITEM_NOT_FOUND
|