iop/agent-task/node_unit_tests/PLAN.md
toki 2d6fde0876 기능: IOP 모노레포 스캐폴드 초기 구현
apps/node 중심 구현 — TCP+JSON transport, Hexagonal Architecture,
mock/cli adapter, fx DI, SQLite 실행 이력 저장.
edge/control-plane/worker는 cobra placeholder.
유닛 테스트 및 통합 테스트 클라이언트 계획서 추가.
2026-05-02 13:20:35 +09:00

12 KiB

Node 유닛 테스트 추가

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

각 체크리스트 항목을 완료한 뒤 [x]로 표시하세요. 중간 검증 명령을 실제로 실행하고 출력을 CODE_REVIEW.md 검증 결과 섹션에 붙여 넣으세요. 계획과 다르게 구현한 부분은 반드시 계획 대비 변경 사항에 기록하세요.

배경

IOP 노드 스캐폴드가 완성되었으나 테스트 파일이 없다. transport 레이어(frame/codec)와 핵심 컴포넌트(mock adapter, router)는 외부 의존성 없이 순수하게 테스트 가능한 구조이므로 유닛 테스트를 추가해 회귀 방지 기반을 만든다.


[TEST-1] transport/frame 유닛 테스트

문제

apps/node/internal/transport/frame.go:16-46readFrame / writeFrame 함수가 4-byte length-prefix 프레이밍을 올바르게 수행하는지 검증하는 테스트가 없다. maxFrameSize 초과 케이스도 미검증.

해결 방법

package transport (화이트박스) 테스트 파일을 생성한다. unexported 함수를 같은 패키지에서 직접 호출한다.

// 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 작성

테스트 작성

이 항목 자체가 테스트 작성 작업이므로 별도 추가 테스트 없음.

중간 검증

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-29encodeEnvelope / decodeEnvelope / encodePayload / decodePayload 함수에 테스트 없음. JSON 인코딩 라운드트립과 잘못된 JSON 입력 시 에러 반환 여부가 미검증.

해결 방법

// 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 작성

테스트 작성

이 항목 자체가 테스트 작성 작업.

중간 검증

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-72Execute 함수가 start → delta+ → complete 순서로 이벤트를 방출하는지, context cancel 시 조기 종료하는지 미검증.

해결 방법

collectSink 헬퍼 타입으로 이벤트를 수집한 뒤 순서와 타입을 검증한다.

// 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 작성

테스트 작성

이 항목 자체가 테스트 작성 작업.

중간 검증

go test ./apps/node/internal/adapters/mock/... -v

기대 결과: 3개 테스트 PASS (cancel 테스트는 50ms 내 종료)


[TEST-4] router 유닛 테스트

문제

apps/node/internal/router/router.go:24-47Resolve 함수가 adapter 이름 미지정 시 default 선택, 명시 시 해당 어댑터 선택, 미등록 어댑터 요청 시 에러 반환하는지 미검증.

해결 방법

// 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 작성

테스트 작성

이 항목 자체가 테스트 작성 작업.

중간 검증

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

최종 검증

# 의존성 먼저 해결
go mod tidy

# 전체 테스트
go test ./apps/node/internal/transport/... \
        ./apps/node/internal/adapters/mock/... \
        ./apps/node/internal/router/... \
        -v -count=1

# 전체 빌드 깨짐 없음 확인
go build ./...

기대 결과: 전체 테스트 PASS, 빌드 에러 없음