<!-- task=edge_node_unit_tests plan=0 tag=TEST -->

# Plan - TEST

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

각 항목의 체크리스트를 완료하고, 중간 검증과 최종 검증 명령을 실행한 뒤 출력을 `CODE_REVIEW.md`의 검증 결과 섹션에 붙여 넣는다. `CODE_REVIEW.md`의 모든 섹션(계획 대비 변경 사항, 주요 설계 결정, 검증 결과)을 실제 내용으로 채운다.

## 배경

`edge_node_arch` 작업으로 구현된 transport 계층과 node 핵심 로직에 유닛 테스트가 없다. `apps/edge/internal/node/registry_test.go` 3개만 존재한다. parser의 역직렬화 정확성, Session의 cancel 관리, Node의 핵심 메서드(OnCapabilityRequest·OnRunRequest·OnCancel) 경로가 검증되지 않은 상태다.

## 의존 관계 및 구현 순서

TEST-1 → TEST-2 → TEST-3 순으로 작성한다. TEST-3은 `*transport.Session` 제로값을 사용하므로 transport 패키지 변경이 없다면 순서 무관하게 컴파일된다.

---

## [TEST-1] apps/node/internal/transport/parser_test.go

### 문제

`parser.go`의 `nodeParserMap()`(line 10)이 3개의 메시지 타입(RunRequest, CancelRequest, CapabilityRequest)을 파싱하는데 역직렬화 정확성을 검증하는 테스트가 없다.

### 해결 방법

각 파서 함수를 직접 호출해 proto.Marshal → parser → 필드 비교로 round-trip을 검증한다. `nodeParserMap()`은 unexported이므로 `package transport`(동일 패키지) 테스트로 작성한다.

```go
// apps/node/internal/transport/parser_test.go
package transport

import (
    "testing"

    "google.golang.org/protobuf/proto"

    iop "iop/proto/gen/iop"
)

func TestNodeParserMap_RunRequest(t *testing.T) {
    m := nodeParserMap()
    original := &iop.RunRequest{RunId: "r1", Adapter: "mock", Model: "v1"}
    b, _ := proto.Marshal(original)

    key := toki.TypeNameOf(original)
    parsed, err := m[key](b)
    if err != nil {
        t.Fatalf("parse error: %v", err)
    }
    got := parsed.(*iop.RunRequest)
    if got.GetRunId() != "r1" || got.GetAdapter() != "mock" {
        t.Errorf("unexpected: %+v", got)
    }
}
```

동일 패턴으로 CancelRequest, CapabilityRequest도 작성.

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

- [ ] `apps/node/internal/transport/parser_test.go` 신규 생성
  - [ ] `package transport` 선언
  - [ ] `TestNodeParserMap_RunRequest`: RunId·Adapter 필드 검증
  - [ ] `TestNodeParserMap_CancelRequest`: RunId 필드 검증
  - [ ] `TestNodeParserMap_CapabilityRequest`: 역직렬화 오류 없음 검증

### 테스트 작성

신규 작성. parser는 새 공개 API가 아니지만 proto 역직렬화 로직의 정확성을 보장하는 회귀 방지 테스트로 필요하다.

### 중간 검증

```
$ PATH=/config/go-sdk/go/bin:/config/go-sdk/bin:$PATH go test ./apps/node/internal/transport/...
```

---

## [TEST-2] apps/node/internal/transport/session_test.go

### 문제

`session.go`의 cancel 관리 메서드 `RegisterCancel`(line 78), `CancelRun`(line 88), `DeregisterCancel`(line 83)에 테스트가 없다. `*transport.Session` 제로값(`&Session{}`)은 `cancelFns sync.Map`이 유효한 제로값이므로 실제 TcpClient 없이 테스트 가능하다.

### 해결 방법

`&transport.Session{}`을 직접 생성해 cancel 함수 등록 → 호출 → 해제 흐름을 검증한다. `package transport_test`로 작성한다.

```go
// apps/node/internal/transport/session_test.go
package transport_test

import (
    "testing"

    "iop/apps/node/internal/transport"
)

func TestSession_RegisterCancel_CancelRun(t *testing.T) {
    sess := &transport.Session{}
    called := false
    sess.RegisterCancel("run-1", func() { called = true })
    sess.CancelRun("run-1")
    if !called {
        t.Fatal("cancel function was not called")
    }
}

func TestSession_DeregisterCancel(t *testing.T) {
    sess := &transport.Session{}
    called := false
    sess.RegisterCancel("run-1", func() { called = true })
    sess.DeregisterCancel("run-1")
    sess.CancelRun("run-1")
    if called {
        t.Fatal("cancel function should not have been called after deregister")
    }
}

func TestSession_CancelRun_UnknownID(t *testing.T) {
    sess := &transport.Session{}
    // must not panic
    sess.CancelRun("nonexistent")
}
```

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

- [ ] `apps/node/internal/transport/session_test.go` 신규 생성
  - [ ] `package transport_test` 선언
  - [ ] `TestSession_RegisterCancel_CancelRun`: cancel 함수 호출 확인
  - [ ] `TestSession_DeregisterCancel`: 해제 후 호출 안됨 확인
  - [ ] `TestSession_CancelRun_UnknownID`: panic 없음 확인

### 테스트 작성

신규 작성. cancel 관리 로직은 `OnRunRequest`의 timeout 경로에서 사용되며, race condition 수정(edge_node_arch REVIEW_REFACTOR)과 유사한 동시성 로직이다. 회귀 방지를 위해 필요하다.

### 중간 검증

```
$ PATH=/config/go-sdk/go/bin:/config/go-sdk/bin:$PATH go test ./apps/node/internal/transport/...
```

---

## [TEST-3] apps/node/internal/node/node_test.go

### 문제

`node.go`의 `OnCapabilityRequest`(line 108), `OnRunRequest`(line 48), `OnCancel`(line 126)에 테스트가 없다.

**의존성 처리 전략:**

| 의존성 | 타입 | 처리 방법 |
|--------|------|-----------|
| `runtime.Router` | interface | 테스트 파일 내 인라인 mock |
| `*adapters.Registry` | struct | 직접 생성 후 no-emit 어댑터 등록 |
| `*store.Store` | struct (SQLite) | `store.New(":memory:", zap.NewNop())` |
| `*transport.Session` | struct | `&transport.Session{}` 제로값 |

`OnRunRequest`에서 `sink.Emit` → `sess.Send` → `client.Send`로 이어지는 경로를 피하기 위해 `no-emit` 테스트 어댑터를 사용한다. 이 어댑터는 `sink.Emit`을 호출하지 않으므로 TcpClient가 nil이어도 안전하다.

### 해결 방법

```go
// apps/node/internal/node/node_test.go
package node_test

import (
    "context"
    "errors"
    "testing"

    "go.uber.org/zap"

    "iop/apps/node/internal/adapters"
    "iop/apps/node/internal/node"
    "iop/apps/node/internal/runtime"
    "iop/apps/node/internal/store"
    "iop/apps/node/internal/transport"
    "iop/packages/config"
    iop "iop/proto/gen/iop"
)

// noEmitAdapter는 sink.Emit을 호출하지 않아 *transport.Session 제로값과 함께 사용 가능하다.
type noEmitAdapter struct{}

func (a *noEmitAdapter) Name() string { return "test" }
func (a *noEmitAdapter) Capabilities(_ context.Context) (runtime.Capabilities, error) {
    return runtime.Capabilities{AdapterName: "test", Models: []string{"v1"}, MaxConcurrency: 1}, nil
}
func (a *noEmitAdapter) Execute(_ context.Context, _ runtime.ExecutionSpec, _ runtime.EventSink) error {
    return nil
}

type fixedRouter struct{ adapterName string }

func (r *fixedRouter) Resolve(_ context.Context, req runtime.RunRequest) (runtime.ExecutionSpec, error) {
    return runtime.ExecutionSpec{RunID: req.RunID, Adapter: r.adapterName}, nil
}

type errorRouter struct{ err error }

func (r *errorRouter) Resolve(_ context.Context, _ runtime.RunRequest) (runtime.ExecutionSpec, error) {
    return runtime.ExecutionSpec{}, r.err
}

func makeNode(t *testing.T, rtr runtime.Router, reg *adapters.Registry) *node.Node {
    t.Helper()
    st, err := store.New(":memory:", zap.NewNop())
    if err != nil {
        t.Fatalf("store: %v", err)
    }
    t.Cleanup(func() { st.Close() })
    cfg := &config.NodeConfig{Node: config.NodeInfo{ID: "test-node"}}
    return node.New(cfg, rtr, reg, st, zap.NewNop())
}
```

**TestOnCapabilityRequest**: mock adapter 1개 등록 후 응답의 nodeID·어댑터 이름·모델 검증
**TestOnRunRequest_RouterError**: `errorRouter` 사용, 반환 에러가 "node: resolve:" 접두사를 포함하는지 검증
**TestOnRunRequest_AdapterNotFound**: `fixedRouter`가 "missing"을 반환하고 registry에 없는 경우 에러 검증
**TestOnRunRequest_Success**: `fixedRouter` + `noEmitAdapter` + `&transport.Session{}`, nil 반환 검증
**TestOnCancel_CallsCancelFn**: `sess.RegisterCancel` 등록 후 `OnCancel` 호출 → cancel 함수 실행 확인

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

- [ ] `apps/node/internal/node/node_test.go` 신규 생성
  - [ ] `package node_test` 선언
  - [ ] `noEmitAdapter` 인라인 정의 (Emit 미호출)
  - [ ] `fixedRouter`, `errorRouter` 인라인 정의
  - [ ] `makeNode` 헬퍼 (in-memory store 사용)
  - [ ] `TestOnCapabilityRequest`: NodeId·AdapterName·Models 검증
  - [ ] `TestOnRunRequest_RouterError`: 에러 접두사 `"node: resolve:"` 검증
  - [ ] `TestOnRunRequest_AdapterNotFound`: 에러 포함 `"not found after routing"` 검증
  - [ ] `TestOnRunRequest_Success`: nil 반환 검증
  - [ ] `TestOnCancel_CallsCancelFn`: cancel 함수 호출 확인

### 테스트 작성

신규 작성. `OnRunRequest`의 error path는 bug fix 성격의 회귀 테스트이며, `OnCapabilityRequest`·`OnCancel`은 새 공개 API의 기본 경로 테스트다. in-memory SQLite를 사용해 외부 의존성 없이 실행 가능하다.

### 중간 검증

```
$ PATH=/config/go-sdk/go/bin:/config/go-sdk/bin:$PATH go test ./apps/node/internal/node/...
```

---

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/node/internal/transport/parser_test.go` (신규) | TEST-1 |
| `apps/node/internal/transport/session_test.go` (신규) | TEST-2 |
| `apps/node/internal/node/node_test.go` (신규) | TEST-3 |

## 최종 검증

```
$ PATH=/config/go-sdk/go/bin:/config/go-sdk/bin:$PATH go test ./apps/node/...
$ PATH=/config/go-sdk/go/bin:/config/go-sdk/bin:$PATH go test ./...
```

모든 테스트 pass, 새로 추가된 3개 파일 외 변경 없음.
