<!-- task=m-branch-event-protosocket-interop-readiness plan=0 tag=TEST -->

# Binary Proto-Socket Branch Event Integration Test

## 이 파일을 읽는 구현 에이전트에게

`CODE_REVIEW-local-G05.md`의 구현 에이전트 소유 섹션을 반드시 채워야 한다. 체크리스트의 마지막 항목(`CODE_REVIEW-*-G??.md` 작성)이 완료되기 전에는 구현이 완료된 것이 아니다.

구현이 사용자 결정, 사용자 소유 외부 환경, 또는 범위 충돌로 막히면 `CODE_REVIEW-local-G05.md`의 `사용자 리뷰 요청` 섹션에 근거를 채우고 active 파일을 그대로 둔 채 멈춘다. 사용자에게 직접 질문하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백은 사용자 리뷰 요청이 아니다.

## 배경

현재 proto-socket `event.subscribe` 관련 테스트는 `dispatcher.Dispatch()` 직접 호출 또는 mock subscriber로만 작성되어 있다. 실제 binary WebSocket 경로(WebSocket binary frame → protobuf `PacketBase` → `structpb.Struct` → Envelope)가 end-to-end로 검증되지 않아, Gito server가 binary proto-socket consumer에게 `branch.updated`를 실제로 전달하는지 계약 수준에서 보장되지 않는다. 이 milestone의 `[producer-fixture]`와 `[webhook-smoke]` task를 하나의 통합 테스트로 충족한다.

## 사용자 리뷰 요청 흐름

구현 중 발생하는 사용자 결정·외부 환경·범위 충돌은 `CODE_REVIEW-local-G05.md`의 `사용자 리뷰 요청` 섹션에 기록하고 active 파일을 그대로 둔다. code-review 스킬이 `USER_REVIEW.md` 작성 여부를 결정한다. 구현 에이전트가 사용자에게 직접 질문하거나 선택지를 채팅에 제시하는 것은 금지한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/control-plane-foundation/milestones/branch-event-protosocket-interop-readiness.md`
- Task ids:
  - `producer-fixture`: 실제 proto-socket Go client 또는 동등한 binary fixture로 `event.subscribe`를 보내고 `branch.updated`를 수신하는 producer-side 테스트 추가
  - `webhook-smoke`: Forgejo push webhook, watched branch listener, event outbox/fanout을 이어 `branch.updated`가 발행되는 smoke 절차
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `services/core/internal/protosocket/envelope.go`
- `services/core/internal/protosocket/server.go`
- `services/core/internal/protosocket/dispatcher.go`
- `services/core/internal/protosocket/subscription.go`
- `services/core/internal/protosocket/subscription_test.go`
- `services/core/internal/controlplane/runtime.go`
- `services/core/internal/controlplane/router.go`
- `services/core/internal/controlplane/router_test.go`
- `services/core/internal/controlplane/runtime_test.go`
- `services/core/internal/events/events.go`
- `services/core/internal/provider/forgejo/push.go`
- `services/core/internal/config/config.go`
- `proto-socket/go/ws_client.go`
- `proto-socket/go/communicator.go` (전체)
- `proto-socket/go/test/ws_test.go`
- `agent-test/local/rules.md`
- `agent-test/local/core-smoke.md`

### 테스트 환경 규칙

- `test_env`: `local`
- `agent-test/local/rules.md` 존재, 읽음
- 매칭 프로파일: `agent-test/local/core-smoke.md` 읽음
- 검증 명령: `cd services/core && go test -count=1 -run TestBinaryProtoSocketBranchUpdated ./internal/controlplane/ -v`
- 전체 패키지 smoke: `cd services/core && go test -count=1 ./...`
- 로컬 DB 불필요 (in-memory runtime 사용)

### 테스트 커버리지 공백

- `[producer-fixture]`: 현재 binary proto-socket 경로를 직접 테스트하는 코드가 없다. `TestEventSubscribeRegistersConnectionFilter`는 dispatcher를 직접 호출하며 binary WebSocket 경로를 거치지 않는다. **공백 있음 → 이번 변경으로 채운다.**
- `[webhook-smoke]`: `TestForgejoPushCreatesWatchedBranchEvent`는 REST 응답과 `GET /api/events`를 확인하지만 proto-socket broadcast 수신을 검증하지 않는다. **공백 있음 → 이번 변경으로 채운다.**

### 심볼 참조

이름 변경 및 삭제 없음.

기존 `registerBranchListener(t *testing.T, router http.Handler, body string)` 헬퍼는 `http.Handler`를 인자로 받으며 `httptest.NewRecorder`를 사용한다. 새 테스트는 실제 서버 URL을 사용하므로 별도 헬퍼가 필요하다. 두 함수는 시그니처가 다르므로 충돌하지 않는다.

### 분할 판단

Single plan을 선택한다. 이유:
- `[producer-fixture]`와 `[webhook-smoke]`는 동일한 end-to-end 테스트 하나로 충족된다.
- 두 task 모두 `controlplane` 패키지에 속하며 같은 파일에 작성된다.
- 분리하면 공통 WebSocket 설정 코드가 중복된다.
- 단일 PASS로 두 milestone Task를 동시에 충족할 수 있다.
- 위험 프로파일이 동일하다(새 test file, production code 변경 없음).

### 범위 결정 근거

- `services/core/internal/controlplane/binary_interop_test.go`: 신규 파일, integration test.
- production 코드 변경 없음. 기존 `router.go`, `runtime.go`, `server.go`는 이미 binary broadcast를 지원한다.
- `agent-contract/`는 이미 별도 작업으로 업데이트됨(wire-contract, schema-neutrality, consumer-interop, ops-docs).

### 빌드 등급

`local-G05`: 단일 test file 추가, production code 변경 없음. binary WebSocket + async 수신의 타이밍 복잡성이 있으나 패턴이 `proto-socket/go/test/ws_test.go`에 명확히 존재하며 context가 bounded하다.

## 구현 체크리스트

- [ ] `services/core/internal/controlplane/binary_interop_test.go` 신규 파일 생성
- [ ] `TestBinaryProtoSocketBranchUpdated` 테스트 구현: `httptest.NewServer` → `websocket.Dial` → `toki.NewWsClient` → binary `event.subscribe` → Forgejo push → binary `branch.updated` broadcast 수신 확인
- [ ] `changed_files` 포함 여부 검증: `before`, `after`, `changed_files[0].path` 검증
- [ ] `cd services/core && go test -count=1 -run TestBinaryProtoSocketBranchUpdated ./internal/controlplane/ -v` 통과 확인
- [ ] `cd services/core && go test -count=1 ./...` 전체 패키지 회귀 확인
- [ ] `CODE_REVIEW-local-G05.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## [TEST-1] Binary proto-socket branch.updated delivery 통합 테스트 추가

### 문제

`services/core/internal/controlplane/router_test.go:183-255` — 기존 `event.subscribe` 테스트는 `dispatcher.Dispatch()`를 직접 호출하거나 `httptest.NewRecorder`를 사용한다. 실제 WebSocket binary frame → `PacketBase` → `structpb.Struct` → `Envelope` 경로가 end-to-end로 검증되지 않는다. `BroadcastEnvelope`가 실제 WebSocket 연결에 binary frame을 전달하는지, 그 payload에 `before`/`after`/`changed_files`가 포함되는지 테스트가 없다.

### 해결 방법

`binary_interop_test.go`를 `package controlplane`로 신규 생성한다.

핵심 흐름:
1. `httptest.NewServer(newRouterWithStore(...))` — 실제 HTTP 서버 시작
2. `websocket.Dial(wsURL, nil)` → `toki.NewWsClient(conn, 0, 0, protosocket.ParserMap())` — binary proto-socket 클라이언트 연결
3. `toki.AddListenerTyped[*structpb.Struct](&client.Communicator, ...)` — 수신 채널 등록 (subscribe 전에 먼저 등록)
4. `toki.SendRequestTyped[*structpb.Struct, *structpb.Struct](&client.Communicator, subMsg, 2*time.Second)` — binary `event.subscribe` 전송 및 응답 확인
5. `http.DefaultClient.Do(pushReq)` — Forgejo push webhook 전송
6. `select { case msg := <-received: ... case <-time.After(3*time.Second): t.Fatal }` — binary broadcast 수신 확인

메시지 라우팅 보장:
- subscribe 응답: `responseNonce > 0` → `handleResponse` → `SendRequestTyped` pending request로 해소 (handler에 도달하지 않음)
- broadcast event: `responseNonce == 0` → `EnqueueInbound` → `AddListenerTyped` 채널로 전달

Before/After:

```go
// Before: 존재하지 않음
// services/core/internal/controlplane/binary_interop_test.go (신규 파일)

// After:
package controlplane

import (
    "bytes"
    "context"
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"
    "time"

    toki "git.toki-labs.com/toki/proto-socket/go"
    "git.toki-labs.com/toki/gito/services/core/internal/config"
    "git.toki-labs.com/toki/gito/services/core/internal/protosocket"
    "google.golang.org/protobuf/types/known/structpb"
    "nhooyr.io/websocket"
)

func TestBinaryProtoSocketBranchUpdated(t *testing.T) {
    ctx := context.Background()

    srv := httptest.NewServer(newRouterWithStore(config.Config{
        AppEnv:          "test",
        ProtoSocketPath: "/proto-socket",
    }, nil, nil))
    t.Cleanup(srv.Close)

    // 1. Register branch watch via REST
    watchReq, _ := http.NewRequest(http.MethodPost, srv.URL+"/api/listeners/branches",
        bytes.NewBufferString(`{"repo_id":"wire-repo","branch":"main","provider":"forgejo"}`))
    watchReq.Header.Set("Content-Type", "application/json")
    watchResp, err := http.DefaultClient.Do(watchReq)
    if err != nil {
        t.Fatalf("register branch watch: %v", err)
    }
    watchResp.Body.Close()
    if watchResp.StatusCode != http.StatusCreated {
        t.Fatalf("register branch watch status: %d", watchResp.StatusCode)
    }

    // 2. Connect binary proto-socket client
    wsURL := strings.Replace(srv.URL, "http://", "ws://", 1) + "/proto-socket"
    conn, _, err := websocket.Dial(ctx, wsURL, nil)
    if err != nil {
        t.Fatalf("websocket dial: %v", err)
    }
    client := toki.NewWsClient(conn, 0, 0, protosocket.ParserMap())
    t.Cleanup(func() { client.Close() })

    // 3. Register listener BEFORE subscribe to avoid missing early broadcasts
    received := make(chan *structpb.Struct, 1)
    toki.AddListenerTyped[*structpb.Struct](&client.Communicator, func(m *structpb.Struct) {
        select {
        case received <- m:
        default:
        }
    })

    // 4. Send event.subscribe via binary proto-socket
    subEnv := protosocket.Envelope{
        ProtocolVersion: protosocket.ProtocolVersion,
        ID:              "sub-1",
        Type:            "request",
        Channel:         "event",
        Action:          "event.subscribe",
        Payload: map[string]any{
            "events":  []any{"branch.updated"},
            "repo_id": "wire-repo",
            "branch":  "main",
        },
    }
    subMsg, err := subEnv.ToStruct()
    if err != nil {
        t.Fatalf("marshal subscribe envelope: %v", err)
    }
    res, err := toki.SendRequestTyped[*structpb.Struct, *structpb.Struct](
        &client.Communicator,
        subMsg,
        2*time.Second,
    )
    if err != nil {
        t.Fatalf("event.subscribe: %v", err)
    }
    resEnv, err := protosocket.EnvelopeFromStruct(res)
    if err != nil {
        t.Fatalf("parse subscribe response: %v", err)
    }
    if resEnv.Type != "response" || resEnv.Action != "event.subscribe" {
        t.Fatalf("subscribe response: type=%q action=%q", resEnv.Type, resEnv.Action)
    }

    // 5. Trigger Forgejo push
    pushBody := `{"ref":"refs/heads/main","before":"sha-before","after":"sha-after",` +
        `"repository":{"name":"wire-repo","full_name":"owner/wire-repo"},` +
        `"commits":[{"id":"sha-after","modified":["docs/README.md"],"added":[],"removed":[]}]}`
    pushReq, _ := http.NewRequest(http.MethodPost,
        srv.URL+"/callbacks/forgejo/push?repo_id=wire-repo",
        bytes.NewBufferString(pushBody))
    pushReq.Header.Set("X-Forgejo-Event", "push")
    pushReq.Header.Set("X-Forgejo-Delivery", "wire-delivery-1")
    pushResp, err := http.DefaultClient.Do(pushReq)
    if err != nil {
        t.Fatalf("forgejo push: %v", err)
    }
    pushResp.Body.Close()
    if pushResp.StatusCode != http.StatusAccepted {
        t.Fatalf("forgejo push status: %d", pushResp.StatusCode)
    }

    // 6. Receive branch.updated on binary proto-socket path
    select {
    case msg := <-received:
        env, err := protosocket.EnvelopeFromStruct(msg)
        if err != nil {
            t.Fatalf("parse event: %v", err)
        }
        if env.Type != "event" || env.Action != "branch.updated" {
            t.Fatalf("event: type=%q action=%q", env.Type, env.Action)
        }
        if env.Payload["before"] != "sha-before" {
            t.Errorf("before: got %v", env.Payload["before"])
        }
        if env.Payload["after"] != "sha-after" {
            t.Errorf("after: got %v", env.Payload["after"])
        }
        files, _ := env.Payload["changed_files"].([]any)
        if len(files) == 0 {
            t.Error("expected changed_files, got none")
        }
    case <-time.After(3 * time.Second):
        t.Fatal("timed out waiting for branch.updated on binary proto-socket path")
    }
}
```

### 수정 파일 및 체크리스트

- [ ] `services/core/internal/controlplane/binary_interop_test.go` 신규 생성

### 테스트 작성

신규 파일 자체가 테스트다. 별도 테스트 없음.

### 중간 검증

```bash
cd services/core && go test -count=1 -run TestBinaryProtoSocketBranchUpdated ./internal/controlplane/ -v
```

기대 출력:
```
=== RUN   TestBinaryProtoSocketBranchUpdated
--- PASS: TestBinaryProtoSocketBranchUpdated (...)
PASS
```

## 수정 파일 요약

| 파일 | 항목 |
| --- | --- |
| `services/core/internal/controlplane/binary_interop_test.go` | TEST-1 (신규) |

## 최종 검증

```bash
cd services/core && go test -count=1 -run TestBinaryProtoSocketBranchUpdated ./internal/controlplane/ -v
```

```bash
cd services/core && go test -count=1 ./...
```

기대 결과: 모든 테스트 PASS. `TestBinaryProtoSocketBranchUpdated`가 binary proto-socket 경로로 `branch.updated`를 수신한다.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-local-G05.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
