apps/node 중심 구현 — TCP+JSON transport, Hexagonal Architecture, mock/cli adapter, fx DI, SQLite 실행 이력 저장. edge/control-plane/worker는 cobra placeholder. 유닛 테스트 및 통합 테스트 클라이언트 계획서 추가.
481 lines
12 KiB
Markdown
481 lines
12 KiB
Markdown
<!-- task=node_unit_tests plan=0 tag=TEST -->
|
|
|
|
# Node 유닛 테스트 추가
|
|
|
|
## 이 파일을 읽는 구현 에이전트에게
|
|
|
|
각 체크리스트 항목을 완료한 뒤 `[x]`로 표시하세요.
|
|
중간 검증 명령을 실제로 실행하고 출력을 `CODE_REVIEW.md` 검증 결과 섹션에 붙여 넣으세요.
|
|
계획과 다르게 구현한 부분은 반드시 `계획 대비 변경 사항`에 기록하세요.
|
|
|
|
## 배경
|
|
|
|
IOP 노드 스캐폴드가 완성되었으나 테스트 파일이 없다.
|
|
transport 레이어(frame/codec)와 핵심 컴포넌트(mock adapter, router)는
|
|
외부 의존성 없이 순수하게 테스트 가능한 구조이므로 유닛 테스트를 추가해
|
|
회귀 방지 기반을 만든다.
|
|
|
|
---
|
|
|
|
### [TEST-1] transport/frame 유닛 테스트
|
|
|
|
**문제**
|
|
|
|
[apps/node/internal/transport/frame.go:16-46](apps/node/internal/transport/frame.go)의
|
|
`readFrame` / `writeFrame` 함수가 4-byte length-prefix 프레이밍을 올바르게 수행하는지
|
|
검증하는 테스트가 없다. maxFrameSize 초과 케이스도 미검증.
|
|
|
|
**해결 방법**
|
|
|
|
`package transport` (화이트박스) 테스트 파일을 생성한다.
|
|
unexported 함수를 같은 패키지에서 직접 호출한다.
|
|
|
|
```go
|
|
// apps/node/internal/transport/frame_test.go (신규)
|
|
package transport
|
|
|
|
import (
|
|
"bytes"
|
|
"testing"
|
|
)
|
|
|
|
func TestWriteReadFrame_roundtrip(t *testing.T) {
|
|
payload := []byte(`{"type":"heartbeat","timestamp":1234}`)
|
|
var buf bytes.Buffer
|
|
if err := writeFrame(&buf, payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := readFrame(&buf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(got, payload) {
|
|
t.Errorf("round-trip mismatch: got %q, want %q", got, payload)
|
|
}
|
|
}
|
|
|
|
func TestWriteReadFrame_empty(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
if err := writeFrame(&buf, []byte{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := readFrame(&buf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 0 {
|
|
t.Errorf("expected empty payload, got %d bytes", len(got))
|
|
}
|
|
}
|
|
|
|
func TestReadFrame_tooLarge(t *testing.T) {
|
|
// 헤더에 maxFrameSize+1 을 직접 기록
|
|
var buf bytes.Buffer
|
|
hdr := make([]byte, 4)
|
|
// encoding/binary 없이 직접 big-endian 기록
|
|
sz := uint32(maxFrameSize + 1)
|
|
hdr[0] = byte(sz >> 24)
|
|
hdr[1] = byte(sz >> 16)
|
|
hdr[2] = byte(sz >> 8)
|
|
hdr[3] = byte(sz)
|
|
buf.Write(hdr)
|
|
_, err := readFrame(&buf)
|
|
if err == nil {
|
|
t.Error("expected error for oversized frame, got nil")
|
|
}
|
|
}
|
|
```
|
|
|
|
**수정 파일 및 체크리스트**
|
|
|
|
- [ ] `apps/node/internal/transport/frame_test.go` 신규 생성
|
|
- [ ] `TestWriteReadFrame_roundtrip` 작성
|
|
- [ ] `TestWriteReadFrame_empty` 작성
|
|
- [ ] `TestReadFrame_tooLarge` 작성
|
|
|
|
**테스트 작성**
|
|
|
|
이 항목 자체가 테스트 작성 작업이므로 별도 추가 테스트 없음.
|
|
|
|
**중간 검증**
|
|
|
|
```bash
|
|
go test ./apps/node/internal/transport/... -run TestWriteReadFrame -v
|
|
go test ./apps/node/internal/transport/... -run TestReadFrame -v
|
|
```
|
|
|
|
기대 결과: 3개 테스트 PASS
|
|
|
|
---
|
|
|
|
### [TEST-2] transport/codec 유닛 테스트
|
|
|
|
**문제**
|
|
|
|
[apps/node/internal/transport/codec.go:10-29](apps/node/internal/transport/codec.go)의
|
|
`encodeEnvelope` / `decodeEnvelope` / `encodePayload` / `decodePayload` 함수에 테스트 없음.
|
|
JSON 인코딩 라운드트립과 잘못된 JSON 입력 시 에러 반환 여부가 미검증.
|
|
|
|
**해결 방법**
|
|
|
|
```go
|
|
// apps/node/internal/transport/codec_test.go (신규)
|
|
package transport
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"iop/packages/protocol"
|
|
)
|
|
|
|
func TestEncodeDecodeEnvelope_roundtrip(t *testing.T) {
|
|
orig := &protocol.Envelope{
|
|
ProtocolVersion: protocol.ProtocolVersion,
|
|
RequestID: "req-001",
|
|
RunID: "run-abc",
|
|
Type: protocol.TypeHeartbeat,
|
|
Payload: []byte(`{"timestamp":999}`),
|
|
}
|
|
data, err := encodeEnvelope(orig)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := decodeEnvelope(data)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.RequestID != orig.RequestID {
|
|
t.Errorf("RequestID: got %q, want %q", got.RequestID, orig.RequestID)
|
|
}
|
|
if got.RunID != orig.RunID {
|
|
t.Errorf("RunID: got %q, want %q", got.RunID, orig.RunID)
|
|
}
|
|
if got.Type != orig.Type {
|
|
t.Errorf("Type: got %q, want %q", got.Type, orig.Type)
|
|
}
|
|
}
|
|
|
|
func TestDecodeEnvelope_invalidJSON(t *testing.T) {
|
|
_, err := decodeEnvelope([]byte(`not-json`))
|
|
if err == nil {
|
|
t.Error("expected error for invalid JSON, got nil")
|
|
}
|
|
}
|
|
|
|
func TestEncodeDecodePayload_roundtrip(t *testing.T) {
|
|
type inner struct {
|
|
Foo string `json:"foo"`
|
|
Bar int `json:"bar"`
|
|
}
|
|
orig := inner{Foo: "hello", Bar: 42}
|
|
data, err := encodePayload(orig)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var got inner
|
|
if err := decodePayload(data, &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Foo != orig.Foo || got.Bar != orig.Bar {
|
|
t.Errorf("payload mismatch: got %+v, want %+v", got, orig)
|
|
}
|
|
}
|
|
```
|
|
|
|
**수정 파일 및 체크리스트**
|
|
|
|
- [ ] `apps/node/internal/transport/codec_test.go` 신규 생성
|
|
- [ ] `TestEncodeDecodeEnvelope_roundtrip` 작성
|
|
- [ ] `TestDecodeEnvelope_invalidJSON` 작성
|
|
- [ ] `TestEncodeDecodePayload_roundtrip` 작성
|
|
|
|
**테스트 작성**
|
|
|
|
이 항목 자체가 테스트 작성 작업.
|
|
|
|
**중간 검증**
|
|
|
|
```bash
|
|
go test ./apps/node/internal/transport/... -run TestEncodeDecodeEnvelope -v
|
|
go test ./apps/node/internal/transport/... -run TestDecodeEnvelope -v
|
|
go test ./apps/node/internal/transport/... -run TestEncodeDecodePayload -v
|
|
```
|
|
|
|
기대 결과: 3개 테스트 PASS
|
|
|
|
---
|
|
|
|
### [TEST-3] mock adapter 유닛 테스트
|
|
|
|
**문제**
|
|
|
|
[apps/node/internal/adapters/mock/mock.go:38-72](apps/node/internal/adapters/mock/mock.go)의
|
|
`Execute` 함수가 start → delta+ → complete 순서로 이벤트를 방출하는지,
|
|
context cancel 시 조기 종료하는지 미검증.
|
|
|
|
**해결 방법**
|
|
|
|
`collectSink` 헬퍼 타입으로 이벤트를 수집한 뒤 순서와 타입을 검증한다.
|
|
|
|
```go
|
|
// apps/node/internal/adapters/mock/mock_test.go (신규)
|
|
package mock
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"iop/apps/node/internal/runtime"
|
|
)
|
|
|
|
type collectSink struct {
|
|
events []runtime.RuntimeEvent
|
|
}
|
|
|
|
func (c *collectSink) Emit(_ context.Context, e runtime.RuntimeEvent) error {
|
|
c.events = append(c.events, e)
|
|
return nil
|
|
}
|
|
|
|
func TestMockExecute_sequence(t *testing.T) {
|
|
m := New(zap.NewNop())
|
|
sink := &collectSink{}
|
|
spec := runtime.ExecutionSpec{
|
|
RunID: "test-run-001",
|
|
Adapter: Name,
|
|
Input: map[string]any{"prompt": "hello world"},
|
|
}
|
|
|
|
if err := m.Execute(context.Background(), spec, sink); err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
if len(sink.events) < 2 {
|
|
t.Fatalf("expected at least 2 events, got %d", len(sink.events))
|
|
}
|
|
|
|
// 첫 이벤트는 반드시 start
|
|
if sink.events[0].Type != runtime.EventTypeStart {
|
|
t.Errorf("first event: got %q, want %q", sink.events[0].Type, runtime.EventTypeStart)
|
|
}
|
|
// 마지막 이벤트는 반드시 complete
|
|
last := sink.events[len(sink.events)-1]
|
|
if last.Type != runtime.EventTypeComplete {
|
|
t.Errorf("last event: got %q, want %q", last.Type, runtime.EventTypeComplete)
|
|
}
|
|
// complete 이벤트에 Usage 포함 여부
|
|
if last.Usage == nil {
|
|
t.Error("complete event: expected Usage, got nil")
|
|
}
|
|
// RunID 일관성
|
|
for i, e := range sink.events {
|
|
if e.RunID != spec.RunID {
|
|
t.Errorf("event[%d].RunID: got %q, want %q", i, e.RunID, spec.RunID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMockExecute_cancel(t *testing.T) {
|
|
m := New(zap.NewNop())
|
|
sink := &collectSink{}
|
|
spec := runtime.ExecutionSpec{
|
|
RunID: "test-cancel-001",
|
|
Adapter: Name,
|
|
Input: map[string]any{"prompt": "long long long long long long long long prompt"},
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
|
defer cancel()
|
|
|
|
err := m.Execute(ctx, spec, sink)
|
|
// context cancel or deadline exceeded 는 정상 종료 케이스
|
|
if err != nil && err != context.Canceled && err != context.DeadlineExceeded {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMockCapabilities(t *testing.T) {
|
|
m := New(zap.NewNop())
|
|
caps, err := m.Capabilities(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if caps.AdapterName != Name {
|
|
t.Errorf("AdapterName: got %q, want %q", caps.AdapterName, Name)
|
|
}
|
|
if len(caps.Models) == 0 {
|
|
t.Error("expected at least one model")
|
|
}
|
|
if caps.MaxConcurrency <= 0 {
|
|
t.Error("expected positive MaxConcurrency")
|
|
}
|
|
}
|
|
```
|
|
|
|
**수정 파일 및 체크리스트**
|
|
|
|
- [ ] `apps/node/internal/adapters/mock/mock_test.go` 신규 생성
|
|
- [ ] `collectSink` 헬퍼 타입 작성
|
|
- [ ] `TestMockExecute_sequence` 작성
|
|
- [ ] `TestMockExecute_cancel` 작성
|
|
- [ ] `TestMockCapabilities` 작성
|
|
|
|
**테스트 작성**
|
|
|
|
이 항목 자체가 테스트 작성 작업.
|
|
|
|
**중간 검증**
|
|
|
|
```bash
|
|
go test ./apps/node/internal/adapters/mock/... -v
|
|
```
|
|
|
|
기대 결과: 3개 테스트 PASS (cancel 테스트는 50ms 내 종료)
|
|
|
|
---
|
|
|
|
### [TEST-4] router 유닛 테스트
|
|
|
|
**문제**
|
|
|
|
[apps/node/internal/router/router.go:24-47](apps/node/internal/router/router.go)의
|
|
`Resolve` 함수가 adapter 이름 미지정 시 default 선택, 명시 시 해당 어댑터 선택,
|
|
미등록 어댑터 요청 시 에러 반환하는지 미검증.
|
|
|
|
**해결 방법**
|
|
|
|
```go
|
|
// apps/node/internal/router/router_test.go (신규)
|
|
package router
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"iop/apps/node/internal/adapters"
|
|
"iop/apps/node/internal/adapters/mock"
|
|
"iop/apps/node/internal/runtime"
|
|
)
|
|
|
|
func newTestRegistry() *adapters.Registry {
|
|
reg := adapters.NewRegistry()
|
|
reg.Register(mock.New(zap.NewNop()))
|
|
return reg
|
|
}
|
|
|
|
func TestRouter_resolveDefault(t *testing.T) {
|
|
r := New(newTestRegistry(), zap.NewNop())
|
|
spec, err := r.Resolve(context.Background(), runtime.RunRequest{
|
|
RunID: "r-001",
|
|
// Adapter 비워두면 default 선택
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Resolve: %v", err)
|
|
}
|
|
if spec.Adapter != mock.Name {
|
|
t.Errorf("Adapter: got %q, want %q", spec.Adapter, mock.Name)
|
|
}
|
|
if spec.RunID != "r-001" {
|
|
t.Errorf("RunID: got %q, want %q", spec.RunID, "r-001")
|
|
}
|
|
}
|
|
|
|
func TestRouter_resolveExplicit(t *testing.T) {
|
|
r := New(newTestRegistry(), zap.NewNop())
|
|
spec, err := r.Resolve(context.Background(), runtime.RunRequest{
|
|
RunID: "r-002",
|
|
Adapter: mock.Name,
|
|
Model: "mock-echo",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Resolve: %v", err)
|
|
}
|
|
if spec.Adapter != mock.Name {
|
|
t.Errorf("Adapter: got %q, want %q", spec.Adapter, mock.Name)
|
|
}
|
|
if spec.Model != "mock-echo" {
|
|
t.Errorf("Model: got %q, want %q", spec.Model, "mock-echo")
|
|
}
|
|
}
|
|
|
|
func TestRouter_resolveUnknown(t *testing.T) {
|
|
r := New(newTestRegistry(), zap.NewNop())
|
|
_, err := r.Resolve(context.Background(), runtime.RunRequest{
|
|
RunID: "r-003",
|
|
Adapter: "nonexistent-adapter",
|
|
})
|
|
if err == nil {
|
|
t.Error("expected error for unknown adapter, got nil")
|
|
}
|
|
}
|
|
|
|
func TestRouter_emptyRegistry(t *testing.T) {
|
|
r := New(adapters.NewRegistry(), zap.NewNop())
|
|
_, err := r.Resolve(context.Background(), runtime.RunRequest{RunID: "r-004"})
|
|
if err == nil {
|
|
t.Error("expected error for empty registry, got nil")
|
|
}
|
|
}
|
|
```
|
|
|
|
**수정 파일 및 체크리스트**
|
|
|
|
- [ ] `apps/node/internal/router/router_test.go` 신규 생성
|
|
- [ ] `newTestRegistry` 헬퍼 작성
|
|
- [ ] `TestRouter_resolveDefault` 작성
|
|
- [ ] `TestRouter_resolveExplicit` 작성
|
|
- [ ] `TestRouter_resolveUnknown` 작성
|
|
- [ ] `TestRouter_emptyRegistry` 작성
|
|
|
|
**테스트 작성**
|
|
|
|
이 항목 자체가 테스트 작성 작업.
|
|
|
|
**중간 검증**
|
|
|
|
```bash
|
|
go test ./apps/node/internal/router/... -v
|
|
```
|
|
|
|
기대 결과: 4개 테스트 PASS
|
|
|
|
---
|
|
|
|
## 의존 관계 및 구현 순서
|
|
|
|
TEST-1, TEST-2 는 외부 iop 패키지 의존성이 없어 독립 실행 가능.
|
|
TEST-3 은 `go.uber.org/zap` 의존 → `go mod tidy` 완료 후 실행.
|
|
TEST-4 는 mock 패키지 의존 → TEST-3 파일 존재 후 실행.
|
|
|
|
권장 순서: TEST-1 → TEST-2 → TEST-3 → TEST-4
|
|
|
|
## 수정 파일 요약
|
|
|
|
| 파일 | 항목 |
|
|
|------|------|
|
|
| `apps/node/internal/transport/frame_test.go` | TEST-1 |
|
|
| `apps/node/internal/transport/codec_test.go` | TEST-2 |
|
|
| `apps/node/internal/adapters/mock/mock_test.go` | TEST-3 |
|
|
| `apps/node/internal/router/router_test.go` | TEST-4 |
|
|
|
|
## 최종 검증
|
|
|
|
```bash
|
|
# 의존성 먼저 해결
|
|
go mod tidy
|
|
|
|
# 전체 테스트
|
|
go test ./apps/node/internal/transport/... \
|
|
./apps/node/internal/adapters/mock/... \
|
|
./apps/node/internal/router/... \
|
|
-v -count=1
|
|
|
|
# 전체 빌드 깨짐 없음 확인
|
|
go build ./...
|
|
```
|
|
|
|
기대 결과: 전체 테스트 PASS, 빌드 에러 없음
|