From 998e72c5b29dc2b8f97d668c61acabf3bbddd33b Mon Sep 17 00:00:00 2001 From: cha-hyunwoo Date: Tue, 18 Aug 2026 18:41:26 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=EC=8B=A4=ED=96=89=20=EC=A0=84=20pai?= =?UTF-8?q?ring=20=EC=A3=BC=EB=AC=B8=20=EC=A1=B0=EA=B1=B4=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=94=EC=9D=B8=EB=94=A9=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pairing/service/PairingRegistry.java | 59 +++++--- .../pairing/service/PairingRegistryTest.java | 130 ++++++++++++++++-- 2 files changed, 163 insertions(+), 26 deletions(-) diff --git a/backend/src/main/java/com/kiobridge/kiobridge/modules/pairing/service/PairingRegistry.java b/backend/src/main/java/com/kiobridge/kiobridge/modules/pairing/service/PairingRegistry.java index 305c90d..a942535 100644 --- a/backend/src/main/java/com/kiobridge/kiobridge/modules/pairing/service/PairingRegistry.java +++ b/backend/src/main/java/com/kiobridge/kiobridge/modules/pairing/service/PairingRegistry.java @@ -18,12 +18,12 @@ /** * 브라우저에 RC5 sessionId를 노출하지 않고 단명 pairingId로 감싼다. * - * pairingId는 이번 연결의 bearer capability다. 프런트는 메모리에만 보관하고, - * 서버는 최초 매핑에 사용한 정규화 입력 전체를 고정한다. 승인 시 입력이 달라졌거나 - * 이미 실행 중인 연결이면 RC5를 호출하지 않는다. + * pairingId는 이번 연결의 bearer capability다. + * 사용자 profileId는 pairing 동안 변경할 수 없고, + * 실행 전까지 최신 정규화 profile/sessionContext를 갱신할 수 있다. * - * SIMULATION_ONLY 단일 인스턴스용 구현이다. 실제품/다중 인스턴스에서는 같은 원자적 - * 상태 전이를 Redis 또는 DB로 옮기고 실제 Agent claim 검증을 앞단에 추가해야 한다. + * 승인 시에는 마지막으로 바인딩한 입력과 승인 입력이 정확히 같은지 검증하고, + * 한 요청만 EXECUTING 상태로 전환한다. */ @Service public class PairingRegistry { @@ -71,31 +71,54 @@ public CreatedPairing register(String rc5SessionId, String environmentId, String ); } - /** 최초 매핑 입력을 고정한다. 같은 입력의 재시도만 멱등하게 허용한다. */ + /** + * 사용자 프로필 identity를 pairing에 묶고, + * 실행 전까지 최신 정규화 입력으로 갱신한다. + */ public void bindInput( - String pairingId, - CanonicalProfile profile, - ChickenStoreSessionContext sessionContext + String pairingId, + CanonicalProfile profile, + ChickenStoreSessionContext sessionContext ) { requireText(pairingId, "pairingId"); Objects.requireNonNull(profile, "profile"); Objects.requireNonNull(sessionContext, "sessionContext"); - pairings.compute(pairingId, (ignoredPairingId, current) -> { // current는 현재 저장된 연결 상태 - Binding binding = requireUsable(current); // 존재 및 만료 검사를 통과한 연결 + pairings.compute(pairingId, (ignoredPairingId, current) -> { + Binding binding = requireUsable(current); + if (binding.status() == Status.EXECUTING) { - throw conflict("PAIRING_ALREADY_EXECUTING", "이미 처리 중인 연결입니다."); + throw conflict( + "PAIRING_ALREADY_EXECUTING", + "이미 처리 중인 연결입니다." + ); } + + // 첫 입력 if (binding.profileSnapshot() == null) { return binding.withInput(profile, sessionContext); } - if (!binding.profileSnapshot().equals(profile)) { - throw conflict("PAIRING_PROFILE_CHANGED", "연결 이후 프로필 정보가 변경되었습니다."); - } - if (!binding.contextSnapshot().equals(sessionContext)) { - throw conflict("PAIRING_CONTEXT_CHANGED", "연결 이후 주문 조건이 변경되었습니다."); + + // pairing에 연결된 사용자는 바꿀 수 없다. + // collectedAt 같은 정규화 메타데이터가 아니라 안정적인 profileId로 identity를 비교한다. + if (!Objects.equals( + binding.profileSnapshot().profileId(), + profile.profileId() + )) { + throw conflict( + "PAIRING_PROFILE_CHANGED", + "연결 이후 사용자 프로필이 변경되었습니다." + ); } - return binding; + + /* + * 같은 사용자라면 실행 전까지 + * 최신 정규화 profile + sessionContext로 갱신한다. + * + * profile도 같이 갱신하는 이유: + * collectedAt 같은 정규화 메타데이터가 새로 만들어질 수 있기 때문. + */ + return binding.withInput(profile, sessionContext); }); } diff --git a/backend/src/test/java/com/kiobridge/kiobridge/modules/pairing/service/PairingRegistryTest.java b/backend/src/test/java/com/kiobridge/kiobridge/modules/pairing/service/PairingRegistryTest.java index 7478745..3b88a88 100644 --- a/backend/src/test/java/com/kiobridge/kiobridge/modules/pairing/service/PairingRegistryTest.java +++ b/backend/src/test/java/com/kiobridge/kiobridge/modules/pairing/service/PairingRegistryTest.java @@ -15,6 +15,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class PairingRegistryTest { @@ -47,17 +48,130 @@ class PairingRegistryTest { } @Test - void 다른_프로필이나_주문조건으로_바꿀_수_없다() { + void 다른_사용자_프로필로는_재바인딩할_수_없다() { String pairingId = newPairing(); - registry.bindInput(pairingId, profile, context); - assertThatThrownBy(() -> registry.bindInput(pairingId, mock(CanonicalProfile.class), context)) - .isInstanceOfSatisfying(ApiException.class, - e -> assertThat(e.code()).isEqualTo("PAIRING_PROFILE_CHANGED")); + CanonicalProfile firstProfile = mock(CanonicalProfile.class); + CanonicalProfile anotherProfile = mock(CanonicalProfile.class); - assertThatThrownBy(() -> registry.bindInput(pairingId, profile, mock(ChickenStoreSessionContext.class))) - .isInstanceOfSatisfying(ApiException.class, - e -> assertThat(e.code()).isEqualTo("PAIRING_CONTEXT_CHANGED")); + when(firstProfile.profileId()).thenReturn("user-1"); + when(anotherProfile.profileId()).thenReturn("user-2"); + + registry.bindInput(pairingId, firstProfile, context); + + assertThatThrownBy( + () -> registry.bindInput(pairingId, anotherProfile, context) + ) + .isInstanceOfSatisfying( + ApiException.class, + e -> assertThat(e.code()) + .isEqualTo("PAIRING_PROFILE_CHANGED") + ); + } + + @Test + void 같은_사용자는_실행_전까지_주문조건을_변경할_수_있다() { + String pairingId = newPairing(); + + CanonicalProfile firstProfile = mock(CanonicalProfile.class); + CanonicalProfile reboundProfile = mock(CanonicalProfile.class); + + when(firstProfile.profileId()).thenReturn("user-1"); + when(reboundProfile.profileId()).thenReturn("user-1"); + + ChickenStoreSessionContext firstContext = + mock(ChickenStoreSessionContext.class); + ChickenStoreSessionContext changedContext = + mock(ChickenStoreSessionContext.class); + + registry.bindInput( + pairingId, + firstProfile, + firstContext + ); + + registry.bindInput( + pairingId, + reboundProfile, + changedContext + ); + + + var reservation = registry.reserveForExecution( + pairingId, + reboundProfile, + changedContext + ); + + assertThat(reservation.rc5SessionId()) + .isEqualTo("SIM-001"); + } + + @Test + void 승인에는_마지막으로_바인딩한_주문조건을_사용해야_한다() { + String pairingId = newPairing(); + + CanonicalProfile firstProfile = mock(CanonicalProfile.class); + CanonicalProfile latestProfile = mock(CanonicalProfile.class); + + when(firstProfile.profileId()).thenReturn("user-1"); + when(latestProfile.profileId()).thenReturn("user-1"); + + ChickenStoreSessionContext oldContext = + mock(ChickenStoreSessionContext.class); + ChickenStoreSessionContext latestContext = + mock(ChickenStoreSessionContext.class); + + registry.bindInput( + pairingId, + firstProfile, + oldContext + ); + + registry.bindInput( + pairingId, + latestProfile, + latestContext + ); + + assertThatThrownBy( + () -> registry.reserveForExecution( + pairingId, + latestProfile, + oldContext + ) + ) + .isInstanceOfSatisfying( + ApiException.class, + e -> assertThat(e.code()) + .isEqualTo("PAIRING_CONTEXT_MISMATCH") + ); + } + + @Test + void 실행이_시작되면_입력을_다시_바인딩할_수_없다() { + String pairingId = newPairing(); + + when(profile.profileId()).thenReturn("user-1"); + + ChickenStoreSessionContext changedContext = + mock(ChickenStoreSessionContext.class); + + registry.bindInput(pairingId, profile, context); + registry.reserveForExecution(pairingId, profile, context); + + assertThatThrownBy( + () -> registry.bindInput( + pairingId, + profile, + changedContext + ) + ) + .isInstanceOfSatisfying( + ApiException.class, + e -> assertThat(e.code()) + .isEqualTo("PAIRING_ALREADY_EXECUTING") + ); } @Test From c75fcc8b2d327eba80b2554aaa038524cd1d3fc1 Mon Sep 17 00:00:00 2001 From: cha-hyunwoo Date: Wed, 19 Aug 2026 13:59:57 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20pairing=20=EC=9E=AC=EB=B0=94?= =?UTF-8?q?=EC=9D=B8=EB=94=A9=20=ED=94=84=EB=A1=A0=ED=8A=B8=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=20=EC=A0=95=ED=95=A9=EC=84=B1=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pairing/service/PairingRegistry.java | 21 +++++---- .../pairing/service/PairingRegistryTest.java | 2 +- frontend/src/app/App.tsx | 44 +------------------ frontend/src/app/screens/Saved.tsx | 38 +--------------- frontend/src/i18n/en.ts | 8 ---- 5 files changed, 16 insertions(+), 97 deletions(-) diff --git a/backend/src/main/java/com/kiobridge/kiobridge/modules/pairing/service/PairingRegistry.java b/backend/src/main/java/com/kiobridge/kiobridge/modules/pairing/service/PairingRegistry.java index a942535..343dbd1 100644 --- a/backend/src/main/java/com/kiobridge/kiobridge/modules/pairing/service/PairingRegistry.java +++ b/backend/src/main/java/com/kiobridge/kiobridge/modules/pairing/service/PairingRegistry.java @@ -18,12 +18,15 @@ /** * 브라우저에 RC5 sessionId를 노출하지 않고 단명 pairingId로 감싼다. * - * pairingId는 이번 연결의 bearer capability다. - * 사용자 profileId는 pairing 동안 변경할 수 없고, - * 실행 전까지 최신 정규화 profile/sessionContext를 갱신할 수 있다. + * pairingId는 이번 연결의 bearer capability다. 사용자 profileId는 pairing 동안 + * 변경할 수 없고, 실행 전까지 같은 사용자의 최신 정규화 profile/sessionContext로 + * 다시 바인딩할 수 있다. 주문표를 다시 고르는 것은 사람이 바뀐 것이 아니기 때문이다. * * 승인 시에는 마지막으로 바인딩한 입력과 승인 입력이 정확히 같은지 검증하고, * 한 요청만 EXECUTING 상태로 전환한다. + * + * SIMULATION_ONLY 단일 인스턴스용 구현이다. 실제품/다중 인스턴스에서는 같은 원자적 + * 상태 전이를 Redis 또는 DB로 옮기고 실제 Agent claim 검증을 앞단에 추가해야 한다. */ @Service public class PairingRegistry { @@ -72,8 +75,8 @@ public CreatedPairing register(String rc5SessionId, String environmentId, String } /** - * 사용자 프로필 identity를 pairing에 묶고, - * 실행 전까지 최신 정규화 입력으로 갱신한다. + * 사용자 identity(profileId)를 pairing에 묶고, 실행 전까지 같은 사용자의 + * 최신 정규화 입력으로 갱신한다. */ public void bindInput( String pairingId, @@ -99,8 +102,8 @@ public void bindInput( return binding.withInput(profile, sessionContext); } - // pairing에 연결된 사용자는 바꿀 수 없다. - // collectedAt 같은 정규화 메타데이터가 아니라 안정적인 profileId로 identity를 비교한다. + // pairing에 연결된 사람은 바꿀 수 없다. 주문표와 함께 달라지는 전체 + // profile 값이 아니라 사람 단위로 안정적인 profileId로 identity를 비교한다. if (!Objects.equals( binding.profileSnapshot().profileId(), profile.profileId() @@ -112,8 +115,8 @@ public void bindInput( } /* - * 같은 사용자라면 실행 전까지 - * 최신 정규화 profile + sessionContext로 갱신한다. + * 같은 사용자라면 실행 전까지 최신 정규화 profile + sessionContext로 + * 갱신한다. 뒤로 가서 다른 주문표를 고른 경우도 이 경로를 탄다. * * profile도 같이 갱신하는 이유: * collectedAt 같은 정규화 메타데이터가 새로 만들어질 수 있기 때문. diff --git a/backend/src/test/java/com/kiobridge/kiobridge/modules/pairing/service/PairingRegistryTest.java b/backend/src/test/java/com/kiobridge/kiobridge/modules/pairing/service/PairingRegistryTest.java index 3b88a88..dd449c4 100644 --- a/backend/src/test/java/com/kiobridge/kiobridge/modules/pairing/service/PairingRegistryTest.java +++ b/backend/src/test/java/com/kiobridge/kiobridge/modules/pairing/service/PairingRegistryTest.java @@ -175,7 +175,7 @@ class PairingRegistryTest { } @Test - void 승인_입력도_최초_스냅샷과_같아야_한다() { + void 승인_입력도_마지막으로_바인딩한_스냅샷과_같아야_한다() { String pairingId = newPairing(); registry.bindInput(pairingId, profile, context); diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 2be53bb..e31f9f8 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -329,17 +329,6 @@ export default function App() { * 아니다 — 시간은 아직 남아 있었고, 다 쓴 것이다(팀 #146). */ const [qr끝난이유, setQr끝난이유] = useState<"만료" | "다썼음">("만료"); - /* - * 이 연결로 이미 담기 시작한 주문표. - * - * 한 연결로는 한 주문표만 담을 수 있다. 서버가 최초 확인 조건을 고정해 두고 - * 승인 때 대조하기 때문이다(PairingRegistry.bindInput) — 다른 주문표를 고르면 - * PAIRING_CONTEXT_CHANGED 로 막히는데, 그때 뜨는 것은 서버 문장이고 되돌아갈 - * 길도 안 준다. 화면에서 **누르기 전에** 말한다(SavedSheetsScreen 의 묶인주문표). - * - * 연결이 끝날 때 같이 비운다 — 새 QR 을 찍으면 아무 주문표나 다시 고를 수 있다. - */ - const [묶인주문표, set묶인주문표] = useState(null); const [orderSheet, setOrderSheet] = useState(null); /** * 지금 고치고 있는 주문표. null 이면 새로 만드는 중이다. @@ -573,25 +562,8 @@ export default function App() { * 보인다. */ if (이어서주문할까) { - /* - * 이미 다른 주문표에 묶인 연결이면 확인 화면으로 안 보낸다. - * - * 여기는 목록을 안 지나는 길이라(저장하고 바로 시작) 목록의 가드를 그냥 - * 지나쳤다. 그대로 두면 묶인 값을 덮어쓰고 확인 화면까지 갔다가 서버가 - * 거기서 막는다 — 되돌릴 수 없는 자리에서 처음 알게 된다. - * - * 목록으로 내려놓으면 왜 안 되는지 그 자리에 적혀 있다(SavedSheetsScreen - * 의 묶인주문표). 주문표는 이미 저장됐으니 잃는 것도 없다. - */ - if (묶인주문표 !== null && 묶인주문표 !== p.id) { - setScreen("saved"); - setTab("menu"); - } else { - setOrderSheet(p); - // 이 연결은 이제 이 주문표에 묶인다(묶인주문표 주석). - if (pairingId) set묶인주문표(p.id); - setScreen("order-confirm"); - } + setOrderSheet(p); + setScreen("order-confirm"); } else { setScreen("saved"); setTab("menu"); @@ -764,7 +736,6 @@ export default function App() { setPairingExpiresAt(null); setPairingKiosk(null); setOrderSheet(null); - set묶인주문표(null); setTab("menu"); setScreen("welcome"); /* @@ -849,7 +820,6 @@ export default function App() { const 되돌리기 = () => { setQr끝난이유("만료"); setPairingId(null); - set묶인주문표(null); setPairingExpiresAt(null); setPairingKiosk(null); setOrderSheet(null); @@ -1377,12 +1347,9 @@ export default function App() { onOrder={(p) => { registerSheet(p); setOrderSheet(p); - // 이 연결은 이제 이 주문표에 묶인다(묶인주문표 주석). - if (pairingId) set묶인주문표(p.id); setScreen("order-confirm"); }} showOrder={fromQr} - 묶인주문표={묶인주문표} /> )} {screen === "order-confirm" && pairingId && orderSheet && ( @@ -1399,7 +1366,6 @@ export default function App() { on연결끝남={() => { setQr끝난이유("다썼음"); setPairingId(null); - set묶인주문표(null); setPairingExpiresAt(null); setPairingKiosk(null); setOrderSheet(null); @@ -1418,9 +1384,6 @@ export default function App() { onHome={() => { setScreen("saved"); setFromQr(false); setPlanId(null); setOrderSheet(null); setPairingId(null); setPairingExpiresAt(null); setPairingKiosk(null); - // 묶인 주문표도 같이 비운다. 안 비우면 다음 QR 연결에서 옛 주문표에 - // 묶인 것으로 남아 다른 주문표가 계속 잠긴다(묶인주문표 주석). - set묶인주문표(null); }} /> )} @@ -1538,9 +1501,6 @@ export default function App() { // 터지면서 QR 만료 화면으로 튕겨 나간다. 방금 다 지웠는데 왜 그러는지 // 사용자는 알 수 없다. setPairingId(null); setPairingExpiresAt(null); setPairingKiosk(null); setFromQr(false); - // 묶인 주문표도 같이 비운다 — "모두 지워요" 에 이 값만 빠질 이유가 없고, - // 남기면 다음 QR 연결에서 다른 주문표가 잠긴다. - set묶인주문표(null); // 새로고침 너머로 넘기려고 적어 둔 것까지 지운다. 이걸 빼면 // "모두 지워요" 라고 말한 뒤 새로고침 한 번에 전부 되돌아온다. 이어쓰기.비우기(); diff --git a/frontend/src/app/screens/Saved.tsx b/frontend/src/app/screens/Saved.tsx index 7678b32..edf0409 100644 --- a/frontend/src/app/screens/Saved.tsx +++ b/frontend/src/app/screens/Saved.tsx @@ -282,7 +282,6 @@ export const 숫자만읽기 = (글: string): number | null => { export function SavedSheetsScreen({ sheets, onAddSheet, onAddVoiceSheet, onDeleteSheet, onEditSheet, onOrder, showOrder = false, - 묶인주문표 = null, }: { sheets: OrderSheet[]; onAddSheet: () => void; @@ -292,18 +291,6 @@ export function SavedSheetsScreen({ onEditSheet: (sheet: OrderSheet) => void; onOrder: (sheet: OrderSheet) => void; showOrder?: boolean; - /** - * 이 연결로 이미 담기 시작한 주문표. null 이면 아직 안 골랐다. - * - * 한 연결로는 한 주문표만 담을 수 있다 — 서버가 최초 확인 조건을 고정해 두고 - * 승인 때 대조한다(PairingRegistry.bindInput). 다른 것을 고르면 그 자리에서 - * 막히는데, 그때 뜨는 것은 서버 문장("연결 이후 주문 조건이 변경되었습니다") - * 이고 되돌아갈 길도 안 준다. - * - * 그래서 여기서 **누르기 전에** 말한다. 위의 '아직 안 고르신 것이 있어요' 와 - * 같은 자리·같은 방식이다 — 막을 때는 무엇을 하면 되는지 같이 말한다. - */ - 묶인주문표?: string | null; }) { const [selectedId, setSelectedId] = useState(sheets[0]?.id ?? null); @@ -329,15 +316,7 @@ export function SavedSheetsScreen({ * 장소 안내와 같은 판단이다 — 막을 때 무엇을 하면 되는지 같이 말한다. */ const 빠진필수 = 고른것 ? 못채운필수축(고른것.place, 고른것.selections) : []; - /* - * 이 연결은 다른 주문표에 묶여 있다(묶인주문표 주석). - * - * 묶인 그 주문표는 그대로 담을 수 있다 — 같은 조건이라 서버가 안 막는다. - * 막는 것은 **다른 것을 고른 경우**뿐이다. - */ - const 다른것에묶임 = 묶인주문표 != null && 고른것 !== null && 고른것.id !== 묶인주문표; - const 묶인이름 = 다른것에묶임 ? sheets.find((p) => p.id === 묶인주문표)?.menuName : undefined; - const 주문가능 = 고른것 !== null && 백엔드가아는장소(고른것) && 빠진필수.length === 0 && !다른것에묶임; + const 주문가능 = 고른것 !== null && 백엔드가아는장소(고른것) && 빠진필수.length === 0; return (
@@ -449,21 +428,6 @@ export function SavedSheetsScreen({
)} - {/* - 다른 주문표에 묶인 연결이다. 무엇을 하면 되는지 같이 말한다 — - "안 됩니다" 만 적으면 사용자는 앱이 고장 난 줄 안다. - - 이름을 못 찾으면(목록에서 지운 경우) 이름 없이 말한다. 지어내지 않는다. - */} - {showOrder && 다른것에묶임 && ( -
- - {묶인이름 - ? tf("지금 연결로는 '{이름}' 만 담을 수 있어요. 다른 주문표로 하시려면 QR 을 다시 찍어 주세요.", { 이름: 묶인이름 }) - : t("지금 연결로는 먼저 고르신 주문표만 담을 수 있어요. 다른 주문표로 하시려면 QR 을 다시 찍어 주세요.")} - -
- )} {showOrder && ( { diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index f68383c..2db1a52 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -654,14 +654,6 @@ export const EN: Record = { "메뉴를 먼저 찾아야 해요": "We need to find the menu first", "주문표를 찾을 수 없어요": "We can’t find that order card", "연결이 만료됐어요. QR을 다시 찍어 주세요": "The session expired. Please scan the QR code again", - /* - * 한 연결로는 한 주문표만 담을 수 있다(PairingRegistry.bindInput). 누르기 전에 - * 말해 주는 줄이라, 여기 빠지면 영어를 쓰는 분은 왜 단추가 잠겼는지 못 읽는다. - */ - "지금 연결로는 '{이름}' 만 담을 수 있어요. 다른 주문표로 하시려면 QR 을 다시 찍어 주세요.": - "This session can only add '{이름}'. To use a different order card, please scan the QR code again.", - "지금 연결로는 먼저 고르신 주문표만 담을 수 있어요. 다른 주문표로 하시려면 QR 을 다시 찍어 주세요.": - "This session can only add the order card you picked first. To use a different one, please scan the QR code again.", "저장하신 조건을 서버가 읽지 못했어요": "The server couldn’t read your saved preferences", "저장하신 조건을 다시 확인해 주세요": "Please check your saved preferences again", "저장하신 알레르기 중에 저희가 확인하지 못한 것이 있어요. 주문표에서 다시 골라 주시거나 직원에게 도움을 청해 주세요.": From c5d90b0562dfa279324b996fd1e3151d4349ad05 Mon Sep 17 00:00:00 2001 From: cha-hyunwoo Date: Wed, 19 Aug 2026 14:07:07 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20pairing=20profileId=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=20=EA=B2=80=EC=A6=9D=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pairing/service/PairingRegistry.java | 9 ++-- .../pairing/service/PairingRegistryTest.java | 43 ++++++++++++++++++- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/backend/src/main/java/com/kiobridge/kiobridge/modules/pairing/service/PairingRegistry.java b/backend/src/main/java/com/kiobridge/kiobridge/modules/pairing/service/PairingRegistry.java index 343dbd1..113305c 100644 --- a/backend/src/main/java/com/kiobridge/kiobridge/modules/pairing/service/PairingRegistry.java +++ b/backend/src/main/java/com/kiobridge/kiobridge/modules/pairing/service/PairingRegistry.java @@ -86,6 +86,7 @@ public void bindInput( requireText(pairingId, "pairingId"); Objects.requireNonNull(profile, "profile"); Objects.requireNonNull(sessionContext, "sessionContext"); + requireText(profile.profileId(), "profile.profileId"); pairings.compute(pairingId, (ignoredPairingId, current) -> { Binding binding = requireUsable(current); @@ -142,10 +143,10 @@ public Reservation reserveForExecution( throw conflict("PAIRING_INPUT_NOT_BOUND", "연결에 사용할 주문표가 아직 지정되지 않았습니다."); } if (!binding.profileSnapshot().equals(profile)) { - throw forbidden("PAIRING_PROFILE_MISMATCH", "최초 연결 프로필과 승인 프로필이 다릅니다."); + throw forbidden("PAIRING_PROFILE_MISMATCH", "마지막으로 바인딩한 프로필과 승인 프로필이 다릅니다."); } if (!binding.contextSnapshot().equals(sessionContext)) { - throw forbidden("PAIRING_CONTEXT_MISMATCH", "최초 확인 조건과 승인 조건이 다릅니다."); + throw forbidden("PAIRING_CONTEXT_MISMATCH", "마지막으로 바인딩한 주문 조건과 승인 조건이 다릅니다."); } if (binding.status() == Status.EXECUTING) { throw conflict("PAIRING_ALREADY_EXECUTING", "이미 처리 중인 연결입니다."); @@ -209,8 +210,8 @@ private record Binding( String rc5SessionId, // 서버 내부에서만 사용하는 실제 RC5 세션 ID String environmentId, // RC5 세션이 속한 시뮬레이션 환경 ID String initialState, // RC5 환경의 시작 상태 - CanonicalProfile profileSnapshot, // 최초 bind 시 고정한 사용자 프로필 - ChickenStoreSessionContext contextSnapshot, // 최초 bind 시 고정한 주문 조건 + CanonicalProfile profileSnapshot, // 실행 전에 마지막으로 바인딩한 사용자 프로필 + ChickenStoreSessionContext contextSnapshot, // 실행 전에 마지막으로 바인딩한 주문 조건 Instant expiresAt, // 이 연결을 사용할 수 있는 마지막 시각 Status status // 입력 대기·활성·실행 중 상태 ) { diff --git a/backend/src/test/java/com/kiobridge/kiobridge/modules/pairing/service/PairingRegistryTest.java b/backend/src/test/java/com/kiobridge/kiobridge/modules/pairing/service/PairingRegistryTest.java index dd449c4..14e7287 100644 --- a/backend/src/test/java/com/kiobridge/kiobridge/modules/pairing/service/PairingRegistryTest.java +++ b/backend/src/test/java/com/kiobridge/kiobridge/modules/pairing/service/PairingRegistryTest.java @@ -3,6 +3,7 @@ import com.kiobridge.kiobridge.common.web.ApiException; import com.kiobridge.kiobridge.contracts.input.context.ChickenStoreSessionContext; import com.kiobridge.kiobridge.contracts.input.profile.CanonicalProfile; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.security.SecureRandom; @@ -24,6 +25,11 @@ class PairingRegistryTest { private final CanonicalProfile profile = mock(CanonicalProfile.class); private final ChickenStoreSessionContext context = mock(ChickenStoreSessionContext.class); + @BeforeEach + void setUp() { + when(profile.profileId()).thenReturn("user-1"); + } + @Test void rc5세션을_노출하지_않는_서로_다른_256비트_pairingId를_발급한다() { var first = registry.register("SIM-001", "chicken-store", "SERVICE_TYPE"); @@ -69,6 +75,28 @@ class PairingRegistryTest { ); } + @Test + void 비어_있는_profileId는_바인딩하지_않는다() { + for (String invalidProfileId : new String[]{null, "", " "}) { + String pairingId = newPairing(); + CanonicalProfile invalidProfile = mock(CanonicalProfile.class); + when(invalidProfile.profileId()).thenReturn(invalidProfileId); + + assertThatThrownBy( + () -> registry.bindInput(pairingId, invalidProfile, context) + ) + .isInstanceOfSatisfying( + ApiException.class, + e -> assertThat(e.code()) + .isEqualTo("REQUIRED_FIELD_MISSING") + ); + + registry.bindInput(pairingId, profile, context); + assertThat(registry.reserveForExecution(pairingId, profile, context).rc5SessionId()) + .isEqualTo("SIM-001"); + } + } + @Test void 같은_사용자는_실행_전까지_주문조건을_변경할_수_있다() { String pairingId = newPairing(); @@ -177,12 +205,23 @@ class PairingRegistryTest { @Test void 승인_입력도_마지막으로_바인딩한_스냅샷과_같아야_한다() { String pairingId = newPairing(); - registry.bindInput(pairingId, profile, context); + CanonicalProfile firstProfile = mock(CanonicalProfile.class); + CanonicalProfile latestProfile = mock(CanonicalProfile.class); + ChickenStoreSessionContext latestContext = mock(ChickenStoreSessionContext.class); + + when(firstProfile.profileId()).thenReturn("user-1"); + when(latestProfile.profileId()).thenReturn("user-1"); + + registry.bindInput(pairingId, firstProfile, context); + registry.bindInput(pairingId, latestProfile, latestContext); assertThatThrownBy(() -> registry.reserveForExecution( - pairingId, mock(CanonicalProfile.class), context + pairingId, firstProfile, latestContext )).isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.code()).isEqualTo("PAIRING_PROFILE_MISMATCH")); + + assertThat(registry.reserveForExecution(pairingId, latestProfile, latestContext).rc5SessionId()) + .isEqualTo("SIM-001"); } @Test From b4cc7ed85e5e780d1841791a3c9549367d885208 Mon Sep 17 00:00:00 2001 From: cha-hyunwoo Date: Wed, 19 Aug 2026 14:11:27 +0900 Subject: [PATCH 4/4] =?UTF-8?q?test:=20pairing=20profileId=20fixture=20?= =?UTF-8?q?=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/OrchestratorControllerPairingTest.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/src/test/java/com/kiobridge/kiobridge/orchestrator/controller/OrchestratorControllerPairingTest.java b/backend/src/test/java/com/kiobridge/kiobridge/orchestrator/controller/OrchestratorControllerPairingTest.java index 815cf79..d247952 100644 --- a/backend/src/test/java/com/kiobridge/kiobridge/orchestrator/controller/OrchestratorControllerPairingTest.java +++ b/backend/src/test/java/com/kiobridge/kiobridge/orchestrator/controller/OrchestratorControllerPairingTest.java @@ -41,6 +41,7 @@ class OrchestratorControllerPairingTest { ChickenStoreSessionContext context = mock(ChickenStoreSessionContext.class); Recommendation recommendation = mock(Recommendation.class); UserDecision decision = mock(UserDecision.class); + when(profile.profileId()).thenReturn("user-1"); String pairingId = registry.register( "SIM-SECRET-001", "chicken-store", "SERVICE_TYPE" ).pairingId(); @@ -97,6 +98,7 @@ class OrchestratorControllerPairingTest { ChickenStoreSessionContext context = mock(ChickenStoreSessionContext.class); Recommendation recommendation = mock(Recommendation.class); UserDecision decision = mock(UserDecision.class); + when(profile.profileId()).thenReturn("user-1"); String pairingId = registry.register( "SIM-SECRET-001", "chicken-store", "SERVICE_TYPE" ).pairingId(); @@ -131,6 +133,8 @@ class OrchestratorControllerPairingTest { ChickenStoreSessionContext context = mock(ChickenStoreSessionContext.class); Recommendation recommendation = mock(Recommendation.class); UserDecision decision = mock(UserDecision.class); + when(boundProfile.profileId()).thenReturn("user-1"); + when(changedProfile.profileId()).thenReturn("user-2"); String pairingId = registry.register( "SIM-SECRET-001", "chicken-store", "SERVICE_TYPE" ).pairingId();