feat: edge node unit tests 및 관련 코드 변경

- node.go: node 핵심 로직 수정
- node_test.go: unit 테스트 업데이트
- parser_test.go: 파서 테스트 업데이트
- session_test.go: 세션 테스트 업데이트
- agent-task 로그 파일 추가
This commit is contained in:
toki 2026-05-02 20:53:49 +09:00
parent 7e8df53ae0
commit de1dcc586a
13 changed files with 1010 additions and 19 deletions

View file

@ -0,0 +1,122 @@
<!-- task=edge_node_unit_tests plan=0 tag=TEST -->
# Code Review Reference - TEST
## 개요
date=2026-05-02
task=edge_node_unit_tests, plan=0, tag=TEST
## 이 파일을 읽는 리뷰 에이전트에게
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
1. `CODE_REVIEW.md` → `code_review_0.log` (N = 기존 code_review_*.log 수)
2. `PLAN.md` → `plan_0.log` (M = 기존 plan_*.log 수)
3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 `PLAN.md` + `CODE_REVIEW.md` 스텁 작성.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [TEST-1] apps/node/internal/transport/parser_test.go | [x] |
| [TEST-2] apps/node/internal/transport/session_test.go | [x] |
| [TEST-3] apps/node/internal/node/node_test.go | [x] |
## 계획 대비 변경 사항
- 계획대로 `apps/node/internal/transport/parser_test.go`, `apps/node/internal/transport/session_test.go`, `apps/node/internal/node/node_test.go`를 신규 작성했다.
- `parser_test.go`의 RunRequest 검증은 계획의 RunId·Adapter에 더해 Model도 함께 확인했다.
- `node_test.go`의 `fixedRouter`는 요청의 Model·Workspace·Policy·Input·TimeoutSec·Metadata를 `ExecutionSpec`으로 전달하도록 작성했다.
## 주요 설계 결정
- `parser_test.go`는 `package transport`를 사용해 unexported `nodeParserMap()`을 직접 검증했다.
- `session_test.go`는 `&transport.Session{}` 제로값을 사용해 TcpClient 없이 cancel 등록·호출·해제 흐름만 검증했다.
- `node_test.go`는 `sink.Emit`을 호출하지 않는 `noEmitAdapter`와 in-memory SQLite store를 사용해 외부 연결 없이 Node 핵심 경로를 검증했다.
## 리뷰어를 위한 체크포인트
- `parser_test.go`가 `package transport`로 선언되어 `nodeParserMap()` 접근 가능한지
- 3개 메시지 타입(RunRequest, CancelRequest, CapabilityRequest) 파서 round-trip 검증 여부
- `session_test.go`에서 `&transport.Session{}` 제로값 사용 (TcpClient nil) 확인
- `TestSession_RegisterCancel_CancelRun`: cancel 호출 여부 확인
- `TestSession_DeregisterCancel`: 해제 후 미호출 확인
- `node_test.go`의 `noEmitAdapter`가 `sink.Emit`을 호출하지 않는지 확인 (TcpClient nil 안전)
- `makeNode`에서 `:memory:` SQLite 사용 여부
- `TestOnCapabilityRequest`: NodeId·AdapterName·Models 필드 검증
- `TestOnRunRequest_RouterError`: 에러 메시지에 `"node: resolve:"` 접두사 포함
- `TestOnRunRequest_AdapterNotFound`: 에러 메시지에 `"not found after routing"` 포함
- `TestOnRunRequest_Success`: nil 반환 확인
- `TestOnCancel_CallsCancelFn`: cancel 함수 호출 확인
- `go test ./apps/node/...` 전체 pass
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### TEST-1 중간 검증
```
$ PATH=/config/go-sdk/go/bin:/config/go-sdk/bin:$PATH go test ./apps/node/internal/transport/...
ok iop/apps/node/internal/transport 0.002s
```
### TEST-2 중간 검증
```
$ PATH=/config/go-sdk/go/bin:/config/go-sdk/bin:$PATH go test ./apps/node/internal/transport/...
ok iop/apps/node/internal/transport 0.002s
```
### TEST-3 중간 검증
```
$ PATH=/config/go-sdk/go/bin:/config/go-sdk/bin:$PATH go test ./apps/node/internal/node/...
ok iop/apps/node/internal/node 0.004s
```
### 최종 검증
```
$ PATH=/config/go-sdk/go/bin:/config/go-sdk/bin:$PATH go test ./apps/node/...
? iop/apps/node/cmd/iop-node [no test files]
? iop/apps/node/internal/adapters [no test files]
? iop/apps/node/internal/adapters/cli [no test files]
? iop/apps/node/internal/adapters/mock [no test files]
? iop/apps/node/internal/adapters/ollama [no test files]
? iop/apps/node/internal/adapters/vllm [no test files]
? iop/apps/node/internal/bootstrap [no test files]
ok iop/apps/node/internal/node (cached)
? iop/apps/node/internal/router [no test files]
? iop/apps/node/internal/runtime [no test files]
? iop/apps/node/internal/store [no test files]
ok iop/apps/node/internal/transport (cached)
$ PATH=/config/go-sdk/go/bin:/config/go-sdk/bin:$PATH go test ./...
? iop/apps/control-plane/cmd/iop-control-plane [no test files]
? iop/apps/edge/cmd/iop-edge [no test files]
? iop/apps/edge/internal/bootstrap [no test files]
ok iop/apps/edge/internal/node (cached)
? iop/apps/edge/internal/transport [no test files]
? iop/apps/node/cmd/iop-node [no test files]
? iop/apps/node/internal/adapters [no test files]
? iop/apps/node/internal/adapters/cli [no test files]
? iop/apps/node/internal/adapters/mock [no test files]
? iop/apps/node/internal/adapters/ollama [no test files]
? iop/apps/node/internal/adapters/vllm [no test files]
? iop/apps/node/internal/bootstrap [no test files]
ok iop/apps/node/internal/node (cached)
? iop/apps/node/internal/router [no test files]
? iop/apps/node/internal/runtime [no test files]
? iop/apps/node/internal/store [no test files]
ok iop/apps/node/internal/transport (cached)
? iop/apps/worker/cmd/iop-worker [no test files]
? iop/packages/auth [no test files]
? iop/packages/config [no test files]
? iop/packages/jobs [no test files]
? iop/packages/metadata [no test files]
? iop/packages/observability [no test files]
? iop/packages/policy [no test files]
? iop/packages/version [no test files]
? iop/proto/gen/iop [no test files]
```

View file

@ -0,0 +1,86 @@
<!-- task=edge_node_unit_tests plan=1 tag=REVIEW_TEST -->
# Code Review Reference - REVIEW_TEST
## 개요
date=2026-05-02
task=edge_node_unit_tests, plan=1, tag=REVIEW_TEST
## 이 파일을 읽는 리뷰 에이전트에게
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
1. `CODE_REVIEW.md` → `code_review_N.log` (N = 기존 code_review_*.log 수)
2. `PLAN.md` → `plan_M.log` (M = 기존 plan_*.log 수)
3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 `PLAN.md` + `CODE_REVIEW.md` 스텁 작성.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_TEST-1] parser_test.go 파서 맵 키 존재 확인 guard 추가 | [x] |
| [REVIEW_TEST-2] session_test.go 동시성 테스트 추가 | [x] |
| [REVIEW_TEST-3] node_test.go adapter 호출 검증 및 store 기록 검증 추가 | [x] |
| [REVIEW_TEST-4] node.go store 에러 로깅 추가 | [x] |
## 계획 대비 변경 사항
계획 범위 내에서 구현했다. `apps/node/internal/store/store.go`는 계획서에 "확인/조건부 수정"으로 적혀 있었고, 기존 `GetRun(ctx, runID)` 메서드가 이미 존재해서 추가 수정은 하지 않았다.
## 주요 설계 결정
- `parser_test.go`는 새 헬퍼를 만들지 않고 각 테스트에 동일한 guard 패턴을 직접 넣어, 실패 메시지가 테스트별 문맥을 그대로 유지하도록 했다.
- `session_test.go` 동시성 검증은 `sync.Map`의 race 안전성 확인이 목적이라 부가 assertion 없이 `RegisterCancel`/`CancelRun`/`DeregisterCancel`의 동시 호출과 race detector 통과 여부에 집중했다.
- `node_test.go`는 새 store mock을 만들지 않고 기존 in-memory SQLite와 `store.GetRun`을 재사용해, adapter 호출 수와 실행 이력 저장 결과를 함께 검증했다.
- `node.go`의 store 에러 처리는 실행 흐름을 바꾸지 않도록 `Warn` 로그만 추가하고 기존 반환값은 유지했다.
## 리뷰어를 위한 체크포인트
- `parser_test.go`에서 3개 테스트 함수 모두 `parsers[key]` 조회 전 `ok` 확인이 추가되었는지
- `session_test.go`에 `TestSession_ConcurrentRegisterCancel`이 추가되었고 `go test -race`를 통과하는지
- `node_test.go`에서 `countingAdapter`가 정의되고 `TestOnRunRequest_Success`에서 `executeCalls == 1` 검증이 추가되었는지
- `makeNode` 시그니처가 `*store.Store`도 반환하도록 변경되었고 기존 호출부가 모두 수정되었는지
- `node.go` line 76과 103의 `_ = ...`가 `if err := ...; err != nil { n.logger.Warn(...) }`로 교체되었는지
- store 에러 로깅이 실행 흐름(return 값)을 변경하지 않는지
- `go test -race ./apps/node/...` 전체 pass
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### REVIEW_TEST-1 중간 검증
```
$ go test ./apps/node/internal/transport/...
zsh:1: command not found: go
```
### REVIEW_TEST-2 중간 검증
```
$ go test -race ./apps/node/internal/transport/...
zsh:1: command not found: go
```
### REVIEW_TEST-3 중간 검증
```
$ go test ./apps/node/internal/node/...
zsh:1: command not found: go
```
### REVIEW_TEST-4 중간 검증
```
$ go test ./apps/node/internal/node/...
zsh:1: command not found: go
```
### 최종 검증
```
$ go test -race ./apps/node/...
zsh:1: command not found: go
$ go test ./...
zsh:1: command not found: go
```

View file

@ -0,0 +1,105 @@
<!-- task=edge_node_unit_tests plan=2 tag=REVIEW_REVIEW_TEST -->
# Code Review Reference - REVIEW_REVIEW_TEST
## 개요
date=2026-05-02
task=edge_node_unit_tests, plan=2, tag=REVIEW_REVIEW_TEST
## 이 파일을 읽는 리뷰 에이전트에게
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
1. `CODE_REVIEW.md` → `code_review_N.log` (N = 기존 code_review_*.log 수)
2. `PLAN.md` → `plan_M.log` (M = 기존 plan_*.log 수)
3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 `PLAN.md` + `CODE_REVIEW.md` 스텁 작성.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REVIEW_TEST-1] Go 환경 확인 및 테스트 실행 | [x] |
## 계획 대비 변경 사항
계획 범위 내에서 검증만 수행했다. 소스 수정은 추가로 필요하지 않았고, 로컬 검증을 위해 Homebrew로 `go@1.24`를 설치한 뒤 해당 바이너리 경로(`/opt/homebrew/opt/go@1.24/libexec/bin`)를 사용해 테스트를 실행했다.
## 주요 설계 결정
- 프로젝트 `go.mod`가 `go 1.24`를 요구하므로 검증 환경도 `go1.24.13`으로 맞췄다.
- PATH 전역 변경은 하지 않고, 각 검증 명령에서 `PATH=/opt/homebrew/opt/go@1.24/libexec/bin:$PATH`를 사용해 재현 가능하게 실행했다.
- 이전 plan에서 반영된 코드 변경은 그대로 두고, 이번 pass에서는 컴파일/테스트 확인만 수행했다.
## 리뷰어를 위한 체크포인트
- Go 환경이 정상 동작하는지 (`go version` 출력 확인)
- `go test -race ./apps/node/internal/transport/...` 출력에 `ok` 포함, FAIL 없음
- `go test -race ./apps/node/internal/node/...` 출력에 `ok` 포함, FAIL 없음
- `go test -race ./apps/node/...` 출력에 `ok` 포함, FAIL 없음
- `go test ./...` 전체 pass
- 컴파일 에러로 인한 코드 수정이 있었다면 변경 내용이 계획 대비 변경 사항에 기록되었는지
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### REVIEW_REVIEW_TEST-1 중간 검증
```
$ go version
go version go1.24.13 darwin/arm64
$ go test -race ./apps/node/internal/transport/...
ok iop/apps/node/internal/transport 1.405s
$ go test -race ./apps/node/internal/node/...
ok iop/apps/node/internal/node 1.471s
```
### 최종 검증
```
$ go test -race ./apps/node/...
? iop/apps/node/cmd/iop-node [no test files]
? iop/apps/node/internal/adapters [no test files]
? iop/apps/node/internal/adapters/cli [no test files]
? iop/apps/node/internal/adapters/mock [no test files]
? iop/apps/node/internal/adapters/ollama [no test files]
? iop/apps/node/internal/adapters/vllm [no test files]
? iop/apps/node/internal/bootstrap [no test files]
ok iop/apps/node/internal/node (cached)
? iop/apps/node/internal/router [no test files]
? iop/apps/node/internal/runtime [no test files]
? iop/apps/node/internal/store [no test files]
ok iop/apps/node/internal/transport (cached)
$ go test ./...
? iop/apps/control-plane/cmd/iop-control-plane [no test files]
? iop/apps/edge/cmd/iop-edge [no test files]
? iop/apps/edge/internal/bootstrap [no test files]
ok iop/apps/edge/internal/node 0.356s
? iop/apps/edge/internal/transport [no test files]
? iop/apps/node/cmd/iop-node [no test files]
? iop/apps/node/internal/adapters [no test files]
? iop/apps/node/internal/adapters/cli [no test files]
? iop/apps/node/internal/adapters/mock [no test files]
? iop/apps/node/internal/adapters/ollama [no test files]
? iop/apps/node/internal/adapters/vllm [no test files]
? iop/apps/node/internal/bootstrap [no test files]
ok iop/apps/node/internal/node 0.392s
? iop/apps/node/internal/router [no test files]
? iop/apps/node/internal/runtime [no test files]
? iop/apps/node/internal/store [no test files]
ok iop/apps/node/internal/transport 0.517s
? iop/apps/worker/cmd/iop-worker [no test files]
? iop/packages/auth [no test files]
? iop/packages/config [no test files]
? iop/packages/jobs [no test files]
? iop/packages/metadata [no test files]
? iop/packages/observability [no test files]
? iop/packages/policy [no test files]
? iop/packages/version [no test files]
? iop/proto/gen/iop [no test files]
```

View file

@ -0,0 +1,29 @@
<!-- task=edge_node_unit_tests -->
# Complete - edge_node_unit_tests
date=2026-05-02
result=PASS
## 요약
3회 plan 루프를 거쳐 edge_node_unit_tests 태스크 완료.
### plan=0 (TEST)
- parser_test.go, session_test.go, node_test.go 신규 작성
- 3개 메시지 타입 파서 round-trip, session cancel 등록/해제, node 핵심 경로(capability/run/cancel) 검증
- 전체 pass
### plan=1 (REVIEW_TEST)
- plan=0 리뷰에서 WARN 판정된 4개 항목 후속 개선
- parser_test.go: 맵 키 존재 guard 추가
- session_test.go: 동시성 테스트 추가 (sync.Map race 검증)
- node_test.go: countingAdapter로 adapter 호출 검증, store 기록 검증 추가
- node.go: store 에러 무시 → Warn 로깅으로 교체
- 코드 변경 완료, 검증 미실행 (Go 바이너리 부재)
### plan=2 (REVIEW_REVIEW_TEST)
- Go 1.24.13 환경 구성 후 전체 검증 실행
- go test -race ./apps/node/... 전체 pass
- go test ./... 전체 pass
- 코드 수정 불필요 확인

View file

@ -0,0 +1,268 @@
<!-- 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개 파일 외 변경 없음.

View file

@ -0,0 +1,248 @@
<!-- task=edge_node_unit_tests plan=1 tag=REVIEW_TEST -->
# Plan - REVIEW_TEST
## 이 파일을 읽는 구현 에이전트에게
각 항목의 체크리스트를 완료하고, 중간 검증과 최종 검증 명령을 실행한 뒤 출력을 `CODE_REVIEW.md`의 검증 결과 섹션에 붙여 넣는다. `CODE_REVIEW.md`의 모든 섹션(계획 대비 변경 사항, 주요 설계 결정, 검증 결과)을 실제 내용으로 채운다.
## 배경
plan=0(TEST) 리뷰에서 WARN 판정된 4개 항목이 있다. parser 테스트의 방어 코드 부재, session 동시성 미검증, node 테스트의 adapter 호출·store 기록 미검증, 그리고 프로덕션 코드의 store 에러 무시 문제다. 이를 후속 개선으로 처리한다.
## 의존 관계 및 구현 순서
REVIEW_TEST-1 → REVIEW_TEST-2는 독립적이다. REVIEW_TEST-3은 REVIEW_TEST-4(프로덕션 코드 변경) 이후에 검증해야 store 에러 로깅 테스트가 가능하므로 REVIEW_TEST-4 → REVIEW_TEST-3 순서를 권장한다.
---
## [REVIEW_TEST-1] parser_test.go 파서 맵 키 존재 확인 guard 추가
### 문제
[parser_test.go](file:///Users/toki/work/go-iop/apps/node/internal/transport/parser_test.go) line 20, 40, 58에서 `parsers[toki.TypeNameOf(original)]`로 맵 키를 조회하지만, 키가 없을 경우 nil function 호출로 panic이 발생한다. `nodeParserMap()`이 특정 타입을 누락하면 panic 대신 명시적 실패 메시지를 출력해야 한다.
### 해결 방법
각 테스트에서 맵 조회 후 `ok` 확인을 추가한다.
Before (`parser_test.go` line 20):
```go
parsed, err := parsers[toki.TypeNameOf(original)](payload)
```
After:
```go
key := toki.TypeNameOf(original)
parser, ok := parsers[key]
if !ok {
t.Fatalf("parser not found for key: %s", key)
}
parsed, err := parser(payload)
```
동일 패턴을 `TestNodeParserMap_CancelRequest` (line 40), `TestNodeParserMap_CapabilityRequest` (line 58)에도 적용한다.
### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/transport/parser_test.go` 수정
- [ ] `TestNodeParserMap_RunRequest`: 맵 키 존재 guard 추가
- [ ] `TestNodeParserMap_CancelRequest`: 맵 키 존재 guard 추가
- [ ] `TestNodeParserMap_CapabilityRequest`: 맵 키 존재 guard 추가
### 테스트 작성
기존 테스트 수정. 신규 테스트 없음.
### 중간 검증
```
go test ./apps/node/internal/transport/...
```
---
## [REVIEW_TEST-2] session_test.go 동시성 테스트 추가
### 문제
[session.go](file:///Users/toki/work/go-iop/apps/node/internal/transport/session.go) line 26에서 `cancelFns sync.Map`을 사용하는 이유가 concurrent access 대응인데, 이를 검증하는 테스트가 없다.
### 해결 방법
`TestSession_ConcurrentRegisterCancel`을 추가한다. 여러 goroutine에서 동시에 `RegisterCancel`, `CancelRun`, `DeregisterCancel`을 호출하고 `go test -race`로 race detector를 통과하는지 확인한다.
```go
func TestSession_ConcurrentRegisterCancel(t *testing.T) {
sess := &transport.Session{}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
id := fmt.Sprintf("run-%d", i)
wg.Add(1)
go func() {
defer wg.Done()
sess.RegisterCancel(id, func() {})
sess.CancelRun(id)
sess.DeregisterCancel(id)
}()
}
wg.Wait()
}
```
### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/transport/session_test.go` 수정
- [ ] `import`에 `"fmt"`, `"sync"` 추가
- [ ] `TestSession_ConcurrentRegisterCancel` 추가: 100개 goroutine에서 Register/Cancel/Deregister 동시 호출
### 테스트 작성
신규 추가. 동시성 로직(sync.Map) 사용의 정합성 검증 목적.
### 중간 검증
```
go test -race ./apps/node/internal/transport/...
```
---
## [REVIEW_TEST-3] node_test.go adapter 호출 검증 및 store 기록 검증 추가
### 문제
[node_test.go](file:///Users/toki/work/go-iop/apps/node/internal/node/node_test.go) `TestOnRunRequest_Success` (line 131)에서 `err == nil` 반환만 검증한다. adapter가 실제로 호출되었는지, store에 run 기록이 삽입·완료 처리되었는지 확인하지 않는다.
### 해결 방법
1. `noEmitAdapter`를 호출 카운터가 있는 `countingAdapter`로 교체한다.
```go
type countingAdapter struct {
executeCalls int32
}
func (a *countingAdapter) Name() string { return "test" }
func (a *countingAdapter) Capabilities(_ context.Context) (runtime.Capabilities, error) {
return runtime.Capabilities{AdapterName: "test", Models: []string{"v1"}, MaxConcurrency: 1}, nil
}
func (a *countingAdapter) Execute(_ context.Context, _ runtime.ExecutionSpec, _ runtime.EventSink) error {
atomic.AddInt32(&a.executeCalls, 1)
return nil
}
```
2. `TestOnRunRequest_Success`에서 `countingAdapter.executeCalls == 1` 검증을 추가한다.
3. store에서 run 기록을 조회하여 status가 `"completed"`인지 검증한다. 이를 위해 `makeNode`가 `*store.Store`를 반환하도록 시그니처를 변경한다.
```go
func makeNode(t *testing.T, rtr runtime.Router, reg *adapters.Registry) (*node.Node, *store.Store) {
// ... 기존 코드 동일
return node.New(cfg, rtr, reg, st, zap.NewNop()), st
}
```
4. store 조회를 위해 `store.Store`에 `GetRun(ctx, runID)` 메서드가 있는지 확인 필요. 없으면 store 패키지에 추가하거나, store DB를 직접 쿼리한다.
### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/node/node_test.go` 수정
- [ ] `countingAdapter` 정의 추가 (`sync/atomic` import)
- [ ] `makeNode` 반환값에 `*store.Store` 추가
- [ ] 기존 `makeNode` 호출부 모두 수정 (반환값 2개)
- [ ] `TestOnRunRequest_Success`: `countingAdapter` 사용, `executeCalls == 1` 검증
- [ ] `TestOnRunRequest_Success`: store에서 run 기록 조회 후 status 검증 (store API 확인 후 결정)
- [ ] `apps/node/internal/store/store.go` 확인
- [ ] `GetRun` 또는 동등한 조회 메서드 존재 여부 확인. 없으면 추가.
### 테스트 작성
기존 테스트 강화. adapter 호출 여부 + store 기록 정합성은 `OnRunRequest` 핵심 경로의 end-to-end 신뢰성에 해당한다.
### 중간 검증
```
go test ./apps/node/internal/node/...
```
---
## [REVIEW_TEST-4] node.go store 에러 로깅 추가
### 문제
[node.go](file:///Users/toki/work/go-iop/apps/node/internal/node/node.go) line 76과 line 103에서 store 에러를 `_ = ...`로 무시한다. store 장애 시 실행 이력이 유실되더라도 로그에 흔적이 없어 디버깅이 불가능하다.
### 해결 방법
Before (`node.go` line 76):
```go
_ = n.store.InsertRun(ctx, store.RunRecord{
```
After:
```go
if err := n.store.InsertRun(ctx, store.RunRecord{
RunID: spec.RunID,
Adapter: spec.Adapter,
Model: spec.Model,
Status: "running",
CreatedAt: time.Now(),
}); err != nil {
n.logger.Warn("store: insert run", zap.String("run_id", spec.RunID), zap.Error(err))
}
```
Before (`node.go` line 103):
```go
_ = n.store.CompleteRun(context.Background(), spec.RunID, status, errMsg)
```
After:
```go
if err := n.store.CompleteRun(context.Background(), spec.RunID, status, errMsg); err != nil {
n.logger.Warn("store: complete run", zap.String("run_id", spec.RunID), zap.Error(err))
}
```
실행 흐름을 중단하지 않고 Warn 레벨 로깅만 추가한다. store 장애가 요청 처리를 막아서는 안 된다.
### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/node/node.go` 수정
- [ ] line 76: `InsertRun` 에러 로깅 추가
- [ ] line 103: `CompleteRun` 에러 로깅 추가
- [ ] 실행 흐름 변경 없음 확인 (return 값 변경 없음)
### 테스트 작성
별도 테스트 불필요. 기존 `TestOnRunRequest_Success`가 in-memory SQLite를 사용하므로 store 에러 경로가 발생하지 않는다. store 에러 경로를 검증하려면 store mock이 필요하지만 현재 `*store.Store`가 concrete type이므로 범위 밖이다.
### 중간 검증
```
go test ./apps/node/internal/node/...
```
---
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/node/internal/transport/parser_test.go` (수정) | REVIEW_TEST-1 |
| `apps/node/internal/transport/session_test.go` (수정) | REVIEW_TEST-2 |
| `apps/node/internal/node/node_test.go` (수정) | REVIEW_TEST-3 |
| `apps/node/internal/node/node.go` (수정) | REVIEW_TEST-4 |
| `apps/node/internal/store/store.go` (확인/수정) | REVIEW_TEST-3 (조건부) |
## 최종 검증
```
go test -race ./apps/node/...
go test ./...
```
모든 테스트 pass, race detector 경고 없음.

View file

@ -0,0 +1,72 @@
<!-- task=edge_node_unit_tests plan=2 tag=REVIEW_REVIEW_TEST -->
# Plan - REVIEW_REVIEW_TEST
## 이 파일을 읽는 구현 에이전트에게
각 항목의 체크리스트를 완료하고, 중간 검증과 최종 검증 명령을 실행한 뒤 출력을 `CODE_REVIEW.md`의 검증 결과 섹션에 붙여 넣는다. `CODE_REVIEW.md`의 모든 섹션(계획 대비 변경 사항, 주요 설계 결정, 검증 결과)을 실제 내용으로 채운다.
## 배경
plan=1(REVIEW_TEST)에서 4개 항목의 코드 변경은 모두 계획대로 완료되었으나, 구현 에이전트 환경에 Go 바이너리가 없어 검증이 한 번도 실행되지 않았다. 코드리뷰에서 소스 대조상 문제는 발견되지 않았으나, 테스트가 실제로 컴파일·통과되는지 확인되지 않은 상태다. 이번 plan은 검증 실행만 수행하며, 컴파일 에러나 테스트 실패가 발견되면 해당 코드를 수정한다.
---
## [REVIEW_REVIEW_TEST-1] Go 환경 확인 및 테스트 실행
### 문제
plan=1 구현 결과물 4개 파일이 실제로 컴파일·테스트 통과하는지 확인되지 않았다.
대상 파일:
- [parser_test.go](file:///Users/toki/work/go-iop/apps/node/internal/transport/parser_test.go)
- [session_test.go](file:///Users/toki/work/go-iop/apps/node/internal/transport/session_test.go)
- [node_test.go](file:///Users/toki/work/go-iop/apps/node/internal/node/node_test.go)
- [node.go](file:///Users/toki/work/go-iop/apps/node/internal/node/node.go)
### 해결 방법
1. Go 바이너리 경로를 확인한다. `which go`, `go version`, 또는 `PATH` 환경변수에 Go SDK 경로를 추가한다.
2. transport 패키지 테스트를 실행한다: `go test -race ./apps/node/internal/transport/...`
3. node 패키지 테스트를 실행한다: `go test -race ./apps/node/internal/node/...`
4. 전체 node 테스트를 실행한다: `go test -race ./apps/node/...`
5. 전체 프로젝트 테스트를 실행한다: `go test ./...`
6. 컴파일 에러 또는 테스트 실패가 발견되면 원인을 파악하고 해당 파일을 수정한다.
### 수정 파일 및 체크리스트
- [ ] Go 환경 확인 (go version 출력 성공)
- [ ] `go test -race ./apps/node/internal/transport/...` pass
- [ ] `go test -race ./apps/node/internal/node/...` pass
- [ ] `go test -race ./apps/node/...` pass
- [ ] `go test ./...` pass
- [ ] 컴파일 에러 발생 시: 해당 파일 수정 후 재실행 (수정 내용을 CODE_REVIEW.md에 기록)
- [ ] 테스트 실패 발생 시: 원인 분석 후 수정 (수정 내용을 CODE_REVIEW.md에 기록)
### 테스트 작성
신규 작성 없음. 기존 테스트의 실행 검증만 수행한다.
### 중간 검증
```
go test -race ./apps/node/internal/transport/...
go test -race ./apps/node/internal/node/...
```
---
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| (수정 없음 — 검증만 실행. 실패 시 해당 파일 수정) | REVIEW_REVIEW_TEST-1 |
## 최종 검증
```
go test -race ./apps/node/...
go test ./...
```
모든 테스트 pass, race detector 경고 없음.

View file

@ -73,13 +73,15 @@ func (n *Node) OnRunRequest(ctx context.Context, sess *transport.Session, req *i
return fmt.Errorf("node: adapter %q not found after routing", spec.Adapter)
}
_ = n.store.InsertRun(ctx, store.RunRecord{
if err := n.store.InsertRun(ctx, store.RunRecord{
RunID: spec.RunID,
Adapter: spec.Adapter,
Model: spec.Model,
Status: "running",
CreatedAt: time.Now(),
})
}); err != nil {
n.logger.Warn("store: insert run", zap.String("run_id", spec.RunID), zap.Error(err))
}
execCtx := ctx
if spec.TimeoutSec > 0 {
@ -100,7 +102,9 @@ func (n *Node) OnRunRequest(ctx context.Context, sess *transport.Session, req *i
errMsg = execErr.Error()
n.logger.Warn("run failed", zap.String("run_id", spec.RunID), zap.Error(execErr))
}
_ = n.store.CompleteRun(context.Background(), spec.RunID, status, errMsg)
if err := n.store.CompleteRun(context.Background(), spec.RunID, status, errMsg); err != nil {
n.logger.Warn("store: complete run", zap.String("run_id", spec.RunID), zap.Error(err))
}
return execErr
}

View file

@ -4,6 +4,7 @@ import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"go.uber.org/zap"
@ -17,11 +18,13 @@ import (
iop "iop/proto/gen/iop"
)
type noEmitAdapter struct{}
type countingAdapter struct {
executeCalls int32
}
func (a *noEmitAdapter) Name() string { return "test" }
func (a *countingAdapter) Name() string { return "test" }
func (a *noEmitAdapter) Capabilities(_ context.Context) (runtime.Capabilities, error) {
func (a *countingAdapter) Capabilities(_ context.Context) (runtime.Capabilities, error) {
return runtime.Capabilities{
AdapterName: "test",
Models: []string{"v1"},
@ -29,7 +32,8 @@ func (a *noEmitAdapter) Capabilities(_ context.Context) (runtime.Capabilities, e
}, nil
}
func (a *noEmitAdapter) Execute(_ context.Context, _ runtime.ExecutionSpec, _ runtime.EventSink) error {
func (a *countingAdapter) Execute(_ context.Context, _ runtime.ExecutionSpec, _ runtime.EventSink) error {
atomic.AddInt32(&a.executeCalls, 1)
return nil
}
@ -58,7 +62,7 @@ func (r *errorRouter) Resolve(_ context.Context, _ runtime.RunRequest) (runtime.
return runtime.ExecutionSpec{}, r.err
}
func makeNode(t *testing.T, rtr runtime.Router, reg *adapters.Registry) *node.Node {
func makeNode(t *testing.T, rtr runtime.Router, reg *adapters.Registry) (*node.Node, *store.Store) {
t.Helper()
st, err := store.New(":memory:", zap.NewNop())
@ -72,13 +76,13 @@ func makeNode(t *testing.T, rtr runtime.Router, reg *adapters.Registry) *node.No
})
cfg := &config.NodeConfig{Node: config.NodeInfo{ID: "test-node"}}
return node.New(cfg, rtr, reg, st, zap.NewNop())
return node.New(cfg, rtr, reg, st, zap.NewNop()), st
}
func TestOnCapabilityRequest(t *testing.T) {
reg := adapters.NewRegistry()
reg.Register(&noEmitAdapter{})
n := makeNode(t, &fixedRouter{adapterName: "test"}, reg)
reg.Register(&countingAdapter{})
n, _ := makeNode(t, &fixedRouter{adapterName: "test"}, reg)
resp, err := n.OnCapabilityRequest(context.Background(), &transport.Session{})
if err != nil {
@ -104,7 +108,7 @@ func TestOnCapabilityRequest(t *testing.T) {
func TestOnRunRequest_RouterError(t *testing.T) {
reg := adapters.NewRegistry()
n := makeNode(t, &errorRouter{err: errors.New("boom")}, reg)
n, _ := makeNode(t, &errorRouter{err: errors.New("boom")}, reg)
err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{RunId: "run-1"})
if err == nil {
@ -117,7 +121,7 @@ func TestOnRunRequest_RouterError(t *testing.T) {
func TestOnRunRequest_AdapterNotFound(t *testing.T) {
reg := adapters.NewRegistry()
n := makeNode(t, &fixedRouter{adapterName: "missing"}, reg)
n, _ := makeNode(t, &fixedRouter{adapterName: "missing"}, reg)
err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{RunId: "run-1"})
if err == nil {
@ -130,8 +134,9 @@ func TestOnRunRequest_AdapterNotFound(t *testing.T) {
func TestOnRunRequest_Success(t *testing.T) {
reg := adapters.NewRegistry()
reg.Register(&noEmitAdapter{})
n := makeNode(t, &fixedRouter{adapterName: "test"}, reg)
adapter := &countingAdapter{}
reg.Register(adapter)
n, st := makeNode(t, &fixedRouter{adapterName: "test"}, reg)
err := n.OnRunRequest(context.Background(), &transport.Session{}, &iop.RunRequest{
RunId: "run-1",
@ -141,11 +146,25 @@ func TestOnRunRequest_Success(t *testing.T) {
if err != nil {
t.Fatalf("run request: %v", err)
}
if got := atomic.LoadInt32(&adapter.executeCalls); got != 1 {
t.Fatalf("expected adapter execute call count 1, got %d", got)
}
run, err := st.GetRun(context.Background(), "run-1")
if err != nil {
t.Fatalf("get run: %v", err)
}
if run == nil {
t.Fatal("expected persisted run record")
}
if run.Status != "completed" {
t.Fatalf("expected completed run status, got %q", run.Status)
}
}
func TestOnCancel_CallsCancelFn(t *testing.T) {
reg := adapters.NewRegistry()
n := makeNode(t, &fixedRouter{adapterName: "test"}, reg)
n, _ := makeNode(t, &fixedRouter{adapterName: "test"}, reg)
sess := &transport.Session{}
called := false
sess.RegisterCancel("run-1", func() { called = true })

View file

@ -17,7 +17,13 @@ func TestNodeParserMap_RunRequest(t *testing.T) {
t.Fatalf("marshal: %v", err)
}
parsed, err := parsers[toki.TypeNameOf(original)](payload)
key := toki.TypeNameOf(original)
parser, ok := parsers[key]
if !ok {
t.Fatalf("parser not found for key: %s", key)
}
parsed, err := parser(payload)
if err != nil {
t.Fatalf("parse: %v", err)
}
@ -37,7 +43,13 @@ func TestNodeParserMap_CancelRequest(t *testing.T) {
t.Fatalf("marshal: %v", err)
}
parsed, err := parsers[toki.TypeNameOf(original)](payload)
key := toki.TypeNameOf(original)
parser, ok := parsers[key]
if !ok {
t.Fatalf("parser not found for key: %s", key)
}
parsed, err := parser(payload)
if err != nil {
t.Fatalf("parse: %v", err)
}
@ -55,7 +67,13 @@ func TestNodeParserMap_CapabilityRequest(t *testing.T) {
t.Fatalf("marshal: %v", err)
}
parsed, err := parsers[toki.TypeNameOf(original)](payload)
key := toki.TypeNameOf(original)
parser, ok := parsers[key]
if !ok {
t.Fatalf("parser not found for key: %s", key)
}
parsed, err := parser(payload)
if err != nil {
t.Fatalf("parse: %v", err)
}

View file

@ -1,6 +1,8 @@
package transport_test
import (
"fmt"
"sync"
"testing"
"iop/apps/node/internal/transport"
@ -36,3 +38,21 @@ func TestSession_CancelRun_UnknownID(t *testing.T) {
sess.CancelRun("missing-run")
}
func TestSession_ConcurrentRegisterCancel(t *testing.T) {
sess := &transport.Session{}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
id := fmt.Sprintf("run-%d", i)
wg.Add(1)
go func(runID string) {
defer wg.Done()
sess.RegisterCancel(runID, func() {})
sess.CancelRun(runID)
sess.DeregisterCancel(runID)
}(id)
}
wg.Wait()
}