nomadcode/services/core/internal/gitosync/runner_test.go
toki f4959a3bab feat: gito-protosocket-consumer-wire-readiness milestone completion
- Update roadmap milestone status
- Add gitosync runner and tests
- Update gitoevents tests
- Adjust docker-compose configuration
- Update core and project READMEs
- Add agent-task for consumer wire readiness
2026-06-16 22:26:44 +09:00

387 lines
11 KiB
Go

package gitosync
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
toki "git.toki-labs.com/toki/proto-socket/go"
"github.com/nomadcode/nomadcode-core/internal/gitoevents"
"github.com/nomadcode/nomadcode-core/internal/protosocket"
"google.golang.org/protobuf/types/known/structpb"
"nhooyr.io/websocket"
)
// branchUpdatedEnvelope builds a contract-shaped broadcast envelope fixture.
func branchUpdatedEnvelope() protosocket.Envelope {
return protosocket.Envelope{
ProtocolVersion: protosocket.ProtocolVersion,
ID: "event-id",
Type: "event",
Channel: "event",
Action: "branch.updated",
Payload: map[string]any{
"id": "event-id",
"type": "branch.updated",
"provider": "forgejo",
"delivery_id": "delivery-id",
"repo_id": "nomadcode",
"branch": "develop",
"before": "old-sha",
"after": "new-sha",
"changed_files": []any{
map[string]any{
"path": "agent-roadmap/phase/example/milestones/example.md",
"change_type": "modified",
},
map[string]any{
"path": "README.md",
"change_type": "modified",
},
},
"observed_at": "2026-06-13T00:00:00Z",
"created_at": "2026-06-13T00:00:00Z",
},
}
}
// TestProtoSocketTransportConnectAndClose verifies basic connect/close using toki.WsClient.
func TestProtoSocketTransportConnectAndClose(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, nil)
if err != nil {
return
}
defer conn.Close(websocket.StatusInternalError, "")
ctx := r.Context()
for {
_, _, err := conn.Read(ctx)
if err != nil {
break
}
}
}))
defer srv.Close()
wsURL := strings.Replace(srv.URL, "http", "ws", 1)
transport := &protoSocketTransport{url: wsURL}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := transport.Connect(ctx); err != nil {
t.Fatalf("Connect failed: %v", err)
}
if transport.client == nil || !transport.client.IsAlive() {
t.Fatal("client should be alive")
}
if err := transport.Close(); err != nil {
t.Fatalf("Close failed: %v", err)
}
}
// TestProtoSocketTransportOverflowAndDisconnect implements REVIEW_API-1.
// It proves backpressure on overflow and that close/disconnect terminal signals
// are not silently dropped.
func TestProtoSocketTransportOverflowAndDisconnect(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, nil)
if err != nil {
return
}
serverClient := toki.NewWsClient(conn, 0, 0, protosocket.ParserMap())
defer serverClient.Close()
// Send 3 events.
// Since buffer capacity is 1, the client will block (backpressure) during the second or third send.
eventEnv := branchUpdatedEnvelope()
eventStruct, _ := eventEnv.ToStruct()
_ = serverClient.Send(eventStruct)
_ = serverClient.Send(eventStruct)
_ = serverClient.Send(eventStruct)
// Wait briefly then close the server-side client to trigger a disconnect
time.Sleep(100 * time.Millisecond)
}))
defer srv.Close()
wsURL := strings.Replace(srv.URL, "http", "ws", 1)
transport := &protoSocketTransport{
url: wsURL,
inboundCap: 1, // Set small buffer capacity to test overflow/backpressure
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := transport.Connect(ctx); err != nil {
t.Fatalf("Connect failed: %v", err)
}
defer transport.Close()
// Wait to let server send events and then disconnect
time.Sleep(200 * time.Millisecond)
// We expect to read events successfully, and finally observe the disconnect EOF without getting stuck.
// 1st Read
env1, err := transport.Read(ctx)
if err != nil {
t.Fatalf("Read 1 failed: %v", err)
}
if env1.Action != "branch.updated" {
t.Errorf("expected branch.updated event, got: %s", env1.Action)
}
// 2nd Read
env2, err := transport.Read(ctx)
if err != nil {
t.Fatalf("Read 2 failed: %v", err)
}
if env2.Action != "branch.updated" {
t.Errorf("expected branch.updated event, got: %s", env2.Action)
}
// 3rd Read (Could either be 3rd event or EOF, depending on toki read timing, but must return either)
_, err = transport.Read(ctx)
// Eventually it must return EOF. We do another read just to ensure EOF.
_, err = transport.Read(ctx)
if err == nil {
t.Fatal("expected terminal EOF error, but got nil")
}
if !errors.Is(err, io.EOF) && !strings.Contains(err.Error(), "context") {
t.Fatalf("expected EOF error, got: %v", err)
}
}
// TestProtoSocketTransportClientRunDeliversBinaryBranchUpdatedEvent implements REVIEW_API-2.
// It verifies that gitoevents.Client.Run works with the production transport and mock server.
func TestProtoSocketTransportClientRunDeliversBinaryBranchUpdatedEvent(t *testing.T) {
subscribeCh := make(chan *structpb.Struct, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, nil)
if err != nil {
return
}
serverClient := toki.NewWsClient(conn, 0, 0, protosocket.ParserMap())
defer serverClient.Close()
toki.AddRequestListenerTyped[*structpb.Struct, *structpb.Struct](&serverClient.Communicator, func(req *structpb.Struct) (*structpb.Struct, error) {
env, err := protosocket.EnvelopeFromStruct(req)
if err != nil {
return nil, err
}
if env.Channel == "event" && env.Action == "event.subscribe" {
subscribeCh <- req
resEnv := protosocket.Envelope{
ProtocolVersion: protosocket.ProtocolVersion,
ID: "res-id",
CorrelationID: env.ID,
Type: "response",
Channel: "event",
Action: "event.subscribe",
}
resStruct, _ := resEnv.ToStruct()
go func() {
time.Sleep(50 * time.Millisecond)
eventEnv := branchUpdatedEnvelope()
eventStruct, _ := eventEnv.ToStruct()
_ = serverClient.Send(eventStruct)
}()
return resStruct, nil
}
return nil, errors.New("unexpected request")
})
<-r.Context().Done()
}))
defer srv.Close()
wsURL := strings.Replace(srv.URL, "http", "ws", 1)
transport := &protoSocketTransport{url: wsURL}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
handlerCh := make(chan gitoevents.BranchUpdatedEvent, 1)
handler := func(ctx context.Context, ev gitoevents.BranchUpdatedEvent) {
handlerCh <- ev
}
client, err := gitoevents.NewClient(transport, gitoevents.Options{
RepoID: "nomadcode",
Branch: "develop",
Handler: handler,
})
if err != nil {
t.Fatalf("NewClient failed: %v", err)
}
runErrCh := make(chan error, 1)
go func() {
runErrCh <- client.Run(ctx)
}()
// Verify subscribe request was received on the server
select {
case reqStruct := <-subscribeCh:
env, err := protosocket.EnvelopeFromStruct(reqStruct)
if err != nil {
t.Fatalf("invalid subscribe request: %v", err)
}
if env.Channel != "event" || env.Action != "event.subscribe" {
t.Fatalf("unexpected subscribe request: %+v", env)
}
events, ok := env.Payload["events"].([]any)
if !ok || len(events) != 1 || events[0] != "branch.updated" {
t.Fatalf("unexpected subscribe request events: %v", env.Payload["events"])
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for subscribe request")
}
// Verify event was received by the handler
select {
case ev := <-handlerCh:
if ev.RepoID != "nomadcode" || ev.Branch != "develop" || ev.After != "new-sha" {
t.Fatalf("unexpected branch updated event: %+v", ev)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for branch updated event")
}
// Cancel context and verify client exits cleanly
cancel()
select {
case runErr := <-runErrCh:
if runErr != nil {
t.Fatalf("expected nil error on clean exit, got: %v", runErr)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for Client.Run to exit")
}
}
// TestProtoSocketTransportClientRunDropsMalformedAndMismatchedStructs implements REVIEW_API-3.
// It verifies that malformed or mismatched structpb.Struct envelopes are safely ignored
// without causing handler side effects or breaking the stream.
func TestProtoSocketTransportClientRunDropsMalformedAndMismatchedStructs(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, nil)
if err != nil {
return
}
serverClient := toki.NewWsClient(conn, 0, 0, protosocket.ParserMap())
defer serverClient.Close()
toki.AddRequestListenerTyped[*structpb.Struct, *structpb.Struct](&serverClient.Communicator, func(req *structpb.Struct) (*structpb.Struct, error) {
env, _ := protosocket.EnvelopeFromStruct(req)
if env.Action == "event.subscribe" {
resEnv := protosocket.Envelope{
ProtocolVersion: protosocket.ProtocolVersion,
ID: "res-id",
CorrelationID: env.ID,
Type: "response",
Channel: "event",
Action: "event.subscribe",
}
resStruct, _ := resEnv.ToStruct()
go func() {
time.Sleep(50 * time.Millisecond)
// 1. Wrong channel/action
wrongEnv := branchUpdatedEnvelope()
wrongEnv.Channel = "wrong"
wrongEnv.Action = "wrong"
wrongStruct, _ := wrongEnv.ToStruct()
_ = serverClient.Send(wrongStruct)
// 2. Missing payload
missingEnv := branchUpdatedEnvelope()
missingEnv.Payload = nil
missingStruct, _ := missingEnv.ToStruct()
_ = serverClient.Send(missingStruct)
// 3. Malformed changed_files
malformedEnv := branchUpdatedEnvelope()
malformedEnv.Payload["changed_files"] = "not-a-list"
malformedStruct, _ := malformedEnv.ToStruct()
_ = serverClient.Send(malformedStruct)
// Wait a bit to ensure Client.handleEnvelope processes drops
time.Sleep(50 * time.Millisecond)
// 4. Send valid target event
validEnv := branchUpdatedEnvelope()
validStruct, _ := validEnv.ToStruct()
_ = serverClient.Send(validStruct)
}()
return resStruct, nil
}
return nil, errors.New("unexpected request")
})
<-r.Context().Done()
}))
defer srv.Close()
wsURL := strings.Replace(srv.URL, "http", "ws", 1)
transport := &protoSocketTransport{url: wsURL}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
handlerCh := make(chan gitoevents.BranchUpdatedEvent, 10)
handler := func(ctx context.Context, ev gitoevents.BranchUpdatedEvent) {
handlerCh <- ev
}
client, err := gitoevents.NewClient(transport, gitoevents.Options{
RepoID: "nomadcode",
Branch: "develop",
Handler: handler,
})
if err != nil {
t.Fatalf("NewClient failed: %v", err)
}
go func() {
_ = client.Run(ctx)
}()
// We expect exactly one event (the valid on-target one) to reach the handler.
select {
case ev := <-handlerCh:
if ev.RepoID != "nomadcode" || ev.Branch != "develop" {
t.Fatalf("unexpected event: %+v", ev)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for valid event")
}
// Verify no other events were received
select {
case extra := <-handlerCh:
t.Fatalf("received unexpected extra event: %+v", extra)
default:
// success
}
}