package relay import ( "context" "net" "testing" "time" "github.com/hashicorp/yamux" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "visiona-backend/internal/session" ) // owner: testing agent(internal/relay local_handle 補測) // // 測試策略:LocalHandle 包住 *yamux.Session。我們不需要真實 WebSocket / 真實 tunnel — // yamux 可直接跑在 net.Pipe() 之上(in-memory、純 Go、不需 docker), // 這已足以驗證 LocalHandle 的「session 開 stream / 關閉 / IsClosed」邏輯。 // 真正端對端的 yamux-over-WebSocket 由 relay 的 integration_test.go 涵蓋。 // newYamuxPair 用 net.Pipe 建一對 yamux session(server 給 LocalHandle,client 端配合)。 // 回傳 server session(LocalHandle 用)+ client session(驅動 Accept,避免 Open 卡住)。 func newYamuxPair(t *testing.T) (server, client *yamux.Session) { t.Helper() c1, c2 := net.Pipe() srv, err := yamux.Server(c1, yamux.DefaultConfig()) require.NoError(t, err, "yamux.Server") cli, err := yamux.Client(c2, yamux.DefaultConfig()) require.NoError(t, err, "yamux.Client") t.Cleanup(func() { _ = srv.Close() _ = cli.Close() _ = c1.Close() _ = c2.Close() }) return srv, cli } // --------------------------------------------------------------------------- // Summary / RecordHeartbeat — 純邏輯(mutex-protected,不碰 yamux) // --------------------------------------------------------------------------- func TestLocalHandle_Summary_ReflectsConstructorArgs(t *testing.T) { srv, _ := newYamuxPair(t) h := NewLocalHandle(srv, "vAc_tok", "203.0.113.7:54321") sum := h.Summary() require.NotNil(t, sum) assert.Equal(t, "vAc_tok", sum.Token) assert.Equal(t, "203.0.113.7:54321", sum.RemoteAddr) assert.False(t, sum.ConnectedAt.IsZero(), "ConnectedAt 應已設定") assert.False(t, sum.LastHeartbeat.IsZero(), "LastHeartbeat 應已設定") // ConnectedAt 與 LastHeartbeat 初始相同 assert.Equal(t, sum.ConnectedAt, sum.LastHeartbeat) } func TestLocalHandle_Summary_ReturnsCopy(t *testing.T) { srv, _ := newYamuxPair(t) h := NewLocalHandle(srv, "vAc_tok", "addr") // caller 改 snapshot 不應影響內部狀態(防中間態觀察 / 並發寫)。 s1 := h.Summary() s1.Token = "MUTATED" s1.LastHeartbeat = time.Unix(0, 0) s2 := h.Summary() assert.Equal(t, "vAc_tok", s2.Token, "Summary() 必須回副本,caller 修改不得污染內部") assert.NotEqual(t, time.Unix(0, 0), s2.LastHeartbeat) } func TestLocalHandle_RecordHeartbeat_UpdatesLastHeartbeat(t *testing.T) { srv, _ := newYamuxPair(t) h := NewLocalHandle(srv, "vAc_tok", "addr") before := h.Summary().LastHeartbeat newTime := before.Add(30 * time.Second) h.RecordHeartbeat(newTime) after := h.Summary().LastHeartbeat assert.True(t, after.Equal(newTime), "RecordHeartbeat 應更新 LastHeartbeat:want %v got %v", newTime, after) // ConnectedAt 不應被動到 assert.Equal(t, before, h.Summary().ConnectedAt) } // --------------------------------------------------------------------------- // OpenStream / IsClosed / Close — 跑在 net.Pipe 上的真 yamux(in-memory) // --------------------------------------------------------------------------- func TestLocalHandle_OpenStream_Succeeds(t *testing.T) { srv, cli := newYamuxPair(t) h := NewLocalHandle(srv, "vAc_tok", "addr") // client 端需要 Accept 才能讓 server 端 Open 完成 handshake。 acceptDone := make(chan net.Conn, 1) go func() { stream, err := cli.Accept() if err == nil { acceptDone <- stream } else { acceptDone <- nil } }() conn, err := h.OpenStream(context.Background()) require.NoError(t, err, "session 健在時 OpenStream 應成功") require.NotNil(t, conn) t.Cleanup(func() { _ = conn.Close() }) select { case s := <-acceptDone: require.NotNil(t, s, "client 端應 Accept 到對應 stream") _ = s.Close() case <-time.After(2 * time.Second): t.Fatal("client 端未在 2s 內 Accept 到 stream") } } func TestLocalHandle_IsClosed_FalseWhenLive(t *testing.T) { srv, _ := newYamuxPair(t) h := NewLocalHandle(srv, "vAc_tok", "addr") assert.False(t, h.IsClosed(), "活的 session IsClosed 應為 false") } func TestLocalHandle_Close_MarksClosed(t *testing.T) { srv, _ := newYamuxPair(t) h := NewLocalHandle(srv, "vAc_tok", "addr") require.NoError(t, h.Close()) assert.True(t, h.IsClosed(), "Close 後 IsClosed 應為 true") } // TestLocalHandle_OpenStream_AfterClose 驗證 close 後 OpenStream 回 ErrSessionClosed。 func TestLocalHandle_OpenStream_AfterClose(t *testing.T) { srv, _ := newYamuxPair(t) h := NewLocalHandle(srv, "vAc_tok", "addr") require.NoError(t, h.Close()) _, err := h.OpenStream(context.Background()) assert.ErrorIs(t, err, session.ErrSessionClosed, "已關閉的 session OpenStream 應回 ErrSessionClosed") } // TestLocalHandle_OpenStream_CtxCancelled 驗證 ctx 已取消時 OpenStream 早退回 ctx.Err()。 func TestLocalHandle_OpenStream_CtxCancelled(t *testing.T) { srv, _ := newYamuxPair(t) h := NewLocalHandle(srv, "vAc_tok", "addr") ctx, cancel := context.WithCancel(context.Background()) cancel() // 立即取消 _, err := h.OpenStream(ctx) require.Error(t, err) assert.ErrorIs(t, err, context.Canceled, "ctx 取消時 OpenStream 應回 ctx.Err()") } // TestLocalHandle_ImplementsHandle 編譯期 + 執行期確認介面一致。 func TestLocalHandle_ImplementsHandle(t *testing.T) { srv, _ := newYamuxPair(t) var _ session.Handle = NewLocalHandle(srv, "t", "a") }