iop/agent-task/edge_node_unit_tests/plan_0.log
toki de1dcc586a feat: edge node unit tests 및 관련 코드 변경
- node.go: node 핵심 로직 수정
- node_test.go: unit 테스트 업데이트
- parser_test.go: 파서 테스트 업데이트
- session_test.go: 세션 테스트 업데이트
- agent-task 로그 파일 추가
2026-05-02 20:53:49 +09:00

268 lines
9.5 KiB
Text

<!-- 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개 파일 외 변경 없음.