package mxgateway import ( "context" "errors" "net" "sync" "testing" "time" pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/grpc/test/bufconn" "google.golang.org/protobuf/types/known/timestamppb" ) func TestGalaxyTestConnectionAttachesAuthAndReturnsOk(t *testing.T) { fake := &fakeGalaxyServer{ testReply: &pb.TestConnectionReply{Ok: true}, } client, cleanup := newGalaxyBufconnClient(t, fake) defer cleanup() ok, err := client.TestConnection(context.Background()) if err != nil { t.Fatalf("TestConnection() error = %v", err) } if !ok { t.Fatalf("TestConnection() ok = false, want true") } if got := fake.testAuth; got != "Bearer test-api-key" { t.Fatalf("authorization metadata = %q, want %q", got, "Bearer test-api-key") } } func TestGalaxyGetLastDeployTimeReturnsAbsentForPresentFalse(t *testing.T) { fake := &fakeGalaxyServer{ deployReply: &pb.GetLastDeployTimeReply{Present: false}, } client, cleanup := newGalaxyBufconnClient(t, fake) defer cleanup() got, present, err := client.GetLastDeployTime(context.Background()) if err != nil { t.Fatalf("GetLastDeployTime() error = %v", err) } if present { t.Fatalf("present = true, want false") } if !got.IsZero() { t.Fatalf("time = %v, want zero", got) } } func TestGalaxyGetLastDeployTimeReturnsTimestampWhenPresent(t *testing.T) { want := time.Date(2026, 4, 28, 12, 34, 56, 0, time.UTC) fake := &fakeGalaxyServer{ deployReply: &pb.GetLastDeployTimeReply{ Present: true, TimeOfLastDeploy: timestamppb.New(want), }, } client, cleanup := newGalaxyBufconnClient(t, fake) defer cleanup() got, present, err := client.GetLastDeployTime(context.Background()) if err != nil { t.Fatalf("GetLastDeployTime() error = %v", err) } if !present { t.Fatalf("present = false, want true") } if !got.Equal(want) { t.Fatalf("time = %v, want %v", got, want) } } func TestGalaxyGetLastDeployTimeReturnsAbsentWhenTimestampNil(t *testing.T) { fake := &fakeGalaxyServer{ deployReply: &pb.GetLastDeployTimeReply{Present: true, TimeOfLastDeploy: nil}, } client, cleanup := newGalaxyBufconnClient(t, fake) defer cleanup() got, present, err := client.GetLastDeployTime(context.Background()) if err != nil { t.Fatalf("GetLastDeployTime() error = %v", err) } if present { t.Fatalf("present = true, want false (nil timestamp)") } if !got.IsZero() { t.Fatalf("time = %v, want zero", got) } } func TestGalaxyDiscoverHierarchyReturnsObjects(t *testing.T) { fake := &fakeGalaxyServer{ discoverReply: &pb.DiscoverHierarchyReply{ Objects: []*pb.GalaxyObject{ { GobjectId: 1, TagName: "TestMachine_001", ContainedName: "TestMachine_001", BrowseName: "TestMachine_001", IsArea: false, CategoryId: 7, TemplateChain: []string{"$Object", "$AppObject"}, Attributes: []*pb.GalaxyAttribute{ { AttributeName: "DownloadPath", FullTagReference: "TestMachine_001.DownloadPath", MxDataType: 8, DataTypeName: "String", }, }, }, { GobjectId: 2, TagName: "TestMachine_002", ContainedName: "TestMachine_002", ParentGobjectId: 1, }, }, }, } client, cleanup := newGalaxyBufconnClient(t, fake) defer cleanup() objects, err := client.DiscoverHierarchy(context.Background()) if err != nil { t.Fatalf("DiscoverHierarchy() error = %v", err) } if len(objects) != 2 { t.Fatalf("len(objects) = %d, want 2", len(objects)) } if objects[0].GetTagName() != "TestMachine_001" { t.Fatalf("objects[0].TagName = %q", objects[0].GetTagName()) } if len(objects[0].GetAttributes()) != 1 { t.Fatalf("len(attributes) = %d, want 1", len(objects[0].GetAttributes())) } if objects[0].GetAttributes()[0].GetFullTagReference() != "TestMachine_001.DownloadPath" { t.Fatalf("FullTagReference = %q", objects[0].GetAttributes()[0].GetFullTagReference()) } } func TestGalaxyDiscoverHierarchyPaginatesAcrossMultiplePages(t *testing.T) { page1 := &pb.DiscoverHierarchyReply{ Objects: []*pb.GalaxyObject{ {GobjectId: 1, TagName: "A"}, {GobjectId: 2, TagName: "B"}, }, NextPageToken: "page-2", TotalObjectCount: 3, } page2 := &pb.DiscoverHierarchyReply{ Objects: []*pb.GalaxyObject{ {GobjectId: 3, TagName: "C"}, }, TotalObjectCount: 3, } fake := &fakeGalaxyServer{ discoverHierarchyReplies: []*pb.DiscoverHierarchyReply{page1, page2}, } client, cleanup := newGalaxyBufconnClient(t, fake) defer cleanup() objs, err := client.DiscoverHierarchy(context.Background()) if err != nil { t.Fatalf("DiscoverHierarchy: %v", err) } if got, want := len(objs), 3; got != want { t.Fatalf("len(objs) = %d, want %d", got, want) } if len(fake.discoverHierarchyCalls) != 2 { t.Fatalf("expected 2 RPC calls, got %d", len(fake.discoverHierarchyCalls)) } if fake.discoverHierarchyCalls[0].GetPageSize() != discoverHierarchyPageSize { t.Fatalf("first call PageSize = %d, want %d", fake.discoverHierarchyCalls[0].GetPageSize(), discoverHierarchyPageSize) } if fake.discoverHierarchyCalls[1].GetPageToken() != "page-2" { t.Fatalf("second call page token = %q, want %q", fake.discoverHierarchyCalls[1].GetPageToken(), "page-2") } } func TestGalaxyDialReturnsGatewayErrorOnRpcFailure(t *testing.T) { fake := &fakeGalaxyServer{failTest: true} client, cleanup := newGalaxyBufconnClient(t, fake) defer cleanup() _, err := client.TestConnection(context.Background()) if err == nil { t.Fatal("TestConnection() error = nil, want error") } var gwErr *GatewayError if !errors.As(err, &gwErr) { t.Fatalf("error %T does not support errors.As(*GatewayError)", err) } if gwErr.Op != "galaxy test connection" { t.Fatalf("Op = %q, want %q", gwErr.Op, "galaxy test connection") } } func TestGalaxyWatchDeployEventsReceivesEventsInOrder(t *testing.T) { bootstrap := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC) deploy1 := time.Date(2026, 4, 28, 10, 5, 0, 0, time.UTC) deploy2 := time.Date(2026, 4, 28, 10, 6, 0, 0, time.UTC) fake := &fakeGalaxyServer{ watchEvents: []*pb.DeployEvent{ { Sequence: 1, ObservedAt: timestamppb.New(bootstrap), TimeOfLastDeploy: timestamppb.New(deploy1), TimeOfLastDeployPresent: true, ObjectCount: 10, AttributeCount: 42, }, { Sequence: 2, ObservedAt: timestamppb.New(deploy2), TimeOfLastDeploy: timestamppb.New(deploy2), TimeOfLastDeployPresent: true, ObjectCount: 11, AttributeCount: 44, }, }, } client, cleanup := newGalaxyBufconnClient(t, fake) defer cleanup() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() events, errs, err := client.WatchDeployEvents(ctx, nil) if err != nil { t.Fatalf("WatchDeployEvents() error = %v", err) } got := make([]*DeployEvent, 0, 2) loop: for { select { case ev, ok := <-events: if !ok { break loop } got = append(got, ev) case errVal := <-errs: if errVal != nil { t.Fatalf("error channel: %v", errVal) } case <-ctx.Done(): t.Fatalf("timeout waiting for events; got %d", len(got)) } } if len(got) != 2 { t.Fatalf("len(events) = %d, want 2", len(got)) } if got[0].GetSequence() != 1 || got[1].GetSequence() != 2 { t.Fatalf("sequences = [%d,%d], want [1,2]", got[0].GetSequence(), got[1].GetSequence()) } if !got[0].GetTimeOfLastDeployPresent() { t.Fatalf("event[0] TimeOfLastDeployPresent = false, want true") } if got[0].GetObjectCount() != 10 || got[0].GetAttributeCount() != 42 { t.Fatalf("event[0] counts = (%d,%d), want (10,42)", got[0].GetObjectCount(), got[0].GetAttributeCount()) } if !got[0].GetTimeOfLastDeploy().AsTime().Equal(deploy1) { t.Fatalf("event[0] TimeOfLastDeploy = %v, want %v", got[0].GetTimeOfLastDeploy().AsTime(), deploy1) } } func TestGalaxyWatchDeployEventsForwardsLastSeenDeployTime(t *testing.T) { fake := &fakeGalaxyServer{ watchEvents: []*pb.DeployEvent{ {Sequence: 7}, }, } client, cleanup := newGalaxyBufconnClient(t, fake) defer cleanup() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() lastSeen := time.Date(2026, 4, 28, 9, 0, 0, 0, time.UTC) events, errs, err := client.WatchDeployEvents(ctx, &lastSeen) if err != nil { t.Fatalf("WatchDeployEvents() error = %v", err) } // Drain everything. loop: for { select { case _, ok := <-events: if !ok { break loop } case errVal := <-errs: if errVal != nil { t.Fatalf("error channel: %v", errVal) } case <-ctx.Done(): t.Fatalf("timeout draining events") } } if fake.watchRequest == nil { t.Fatalf("server did not receive a request") } gotTs := fake.watchRequest.GetLastSeenDeployTime() if gotTs == nil { t.Fatalf("LastSeenDeployTime = nil, want %v", lastSeen) } if !gotTs.AsTime().Equal(lastSeen) { t.Fatalf("LastSeenDeployTime = %v, want %v", gotTs.AsTime(), lastSeen) } } func TestGalaxyWatchDeployEventsCancelTearsDownStream(t *testing.T) { fake := &fakeGalaxyServer{ watchEvents: []*pb.DeployEvent{ {Sequence: 1}, }, watchHoldOpen: true, } client, cleanup := newGalaxyBufconnClient(t, fake) defer cleanup() streamCtx, cancelStream := context.WithCancel(context.Background()) events, errs, err := client.WatchDeployEvents(streamCtx, nil) if err != nil { t.Fatalf("WatchDeployEvents() error = %v", err) } // Wait for the bootstrap event to arrive. select { case ev, ok := <-events: if !ok { t.Fatalf("events channel closed before delivering bootstrap") } if ev.GetSequence() != 1 { t.Fatalf("got seq=%d, want 1", ev.GetSequence()) } case <-time.After(2 * time.Second): t.Fatalf("timeout waiting for bootstrap event") } // Cancel the stream; both channels must close cleanly without delivering an error. cancelStream() deadline := time.After(2 * time.Second) for events != nil || errs != nil { select { case _, ok := <-events: if !ok { events = nil } case errVal, ok := <-errs: if !ok { errs = nil continue } if errVal != nil { t.Fatalf("error after cancel: %v", errVal) } case <-deadline: t.Fatalf("channels did not close after cancel; events nil=%v errs nil=%v", events == nil, errs == nil) } } } func newGalaxyBufconnClient(t *testing.T, fake *fakeGalaxyServer) (*GalaxyClient, func()) { t.Helper() listener := bufconn.Listen(bufSize) server := grpc.NewServer() pb.RegisterGalaxyRepositoryServer(server, fake) go func() { if err := server.Serve(listener); err != nil && !errors.Is(err, grpc.ErrServerStopped) { t.Errorf("bufconn server failed: %v", err) } }() dialer := func(ctx context.Context, _ string) (net.Conn, error) { return listener.DialContext(ctx) } client, err := DialGalaxy(context.Background(), Options{ Endpoint: "bufnet", APIKey: "test-api-key", Plaintext: true, DialOptions: []grpc.DialOption{ grpc.WithContextDialer(dialer), }, }) if err != nil { t.Fatalf("DialGalaxy() error = %v", err) } return client, func() { client.Close() server.Stop() listener.Close() } } type fakeGalaxyServer struct { pb.UnimplementedGalaxyRepositoryServer testReply *pb.TestConnectionReply testAuth string failTest bool deployReply *pb.GetLastDeployTimeReply discoverReply *pb.DiscoverHierarchyReply discoverHierarchyCalls []*pb.DiscoverHierarchyRequest discoverHierarchyReplies []*pb.DiscoverHierarchyReply watchEvents []*pb.DeployEvent watchRequest *pb.WatchDeployEventsRequest watchSendInterval time.Duration watchHoldOpen bool browseChildrenCalls []*pb.BrowseChildrenRequest browseChildrenReplies []*pb.BrowseChildrenReply browseChildrenError error } func (s *fakeGalaxyServer) TestConnection(ctx context.Context, req *pb.TestConnectionRequest) (*pb.TestConnectionReply, error) { s.testAuth = authorizationFromContext(ctx) if s.failTest { return nil, errors.New("simulated failure") } if s.testReply != nil { return s.testReply, nil } return &pb.TestConnectionReply{Ok: true}, nil } func (s *fakeGalaxyServer) GetLastDeployTime(ctx context.Context, req *pb.GetLastDeployTimeRequest) (*pb.GetLastDeployTimeReply, error) { if s.deployReply != nil { return s.deployReply, nil } return &pb.GetLastDeployTimeReply{Present: false}, nil } func (s *fakeGalaxyServer) DiscoverHierarchy(ctx context.Context, req *pb.DiscoverHierarchyRequest) (*pb.DiscoverHierarchyReply, error) { s.discoverHierarchyCalls = append(s.discoverHierarchyCalls, req) if len(s.discoverHierarchyReplies) > 0 { reply := s.discoverHierarchyReplies[0] s.discoverHierarchyReplies = s.discoverHierarchyReplies[1:] return reply, nil } if s.discoverReply != nil { return s.discoverReply, nil } return &pb.DiscoverHierarchyReply{}, nil } func (s *fakeGalaxyServer) WatchDeployEvents(req *pb.WatchDeployEventsRequest, stream grpc.ServerStreamingServer[pb.DeployEvent]) error { s.watchRequest = req for _, event := range s.watchEvents { if err := stream.Send(event); err != nil { return err } if s.watchSendInterval > 0 { select { case <-time.After(s.watchSendInterval): case <-stream.Context().Done(): return stream.Context().Err() } } } if s.watchHoldOpen { <-stream.Context().Done() } return nil } func (s *fakeGalaxyServer) BrowseChildren(ctx context.Context, req *pb.BrowseChildrenRequest) (*pb.BrowseChildrenReply, error) { s.browseChildrenCalls = append(s.browseChildrenCalls, req) if s.browseChildrenError != nil { err := s.browseChildrenError s.browseChildrenError = nil return nil, err } if len(s.browseChildrenReplies) == 0 { return &pb.BrowseChildrenReply{}, nil } reply := s.browseChildrenReplies[0] s.browseChildrenReplies = s.browseChildrenReplies[1:] return reply, nil } func obj(id int32, tag string, isArea bool) *pb.GalaxyObject { return &pb.GalaxyObject{ GobjectId: id, TagName: tag, BrowseName: tag, IsArea: isArea, } } func buildBrowseReply(children []*pb.GalaxyObject, hasChildren []bool, seq uint64) *pb.BrowseChildrenReply { return &pb.BrowseChildrenReply{ TotalChildCount: int32(len(children)), CacheSequence: seq, Children: children, ChildHasChildren: hasChildren, } } func TestGalaxyBrowseNoParentReturnsRoots(t *testing.T) { fake := &fakeGalaxyServer{ browseChildrenReplies: []*pb.BrowseChildrenReply{ buildBrowseReply( []*pb.GalaxyObject{obj(1, "Plant", true), obj(99, "Other", false)}, []bool{true, false}, 7, ), }, } client, cleanup := newGalaxyBufconnClient(t, fake) defer cleanup() roots, err := client.Browse(context.Background(), nil) if err != nil { t.Fatalf("Browse: %v", err) } if got, want := len(roots), 2; got != want { t.Fatalf("len(roots) = %d, want %d", got, want) } if roots[0].Object().GetTagName() != "Plant" { t.Fatalf("roots[0].TagName = %q", roots[0].Object().GetTagName()) } if !roots[0].HasChildrenHint() { t.Fatal("roots[0].HasChildrenHint = false, want true") } if roots[0].IsExpanded() { t.Fatal("roots[0].IsExpanded = true, want false") } if roots[1].HasChildrenHint() { t.Fatal("roots[1].HasChildrenHint = true, want false") } if len(fake.browseChildrenCalls) != 1 { t.Fatalf("BrowseChildren calls = %d, want 1", len(fake.browseChildrenCalls)) } if fake.browseChildrenCalls[0].GetParent() != nil { t.Fatalf("root browse should not set Parent oneof, got %T", fake.browseChildrenCalls[0].GetParent()) } } func TestGalaxyBrowseExpandPopulatesChildrenAndMarksExpanded(t *testing.T) { fake := &fakeGalaxyServer{ browseChildrenReplies: []*pb.BrowseChildrenReply{ buildBrowseReply( []*pb.GalaxyObject{obj(1, "Plant", true)}, []bool{true}, 1, ), buildBrowseReply( []*pb.GalaxyObject{obj(10, "Area1", true), obj(11, "Tank1", false)}, []bool{true, false}, 1, ), }, } client, cleanup := newGalaxyBufconnClient(t, fake) defer cleanup() roots, err := client.Browse(context.Background(), nil) if err != nil { t.Fatalf("Browse: %v", err) } if len(roots) != 1 { t.Fatalf("len(roots) = %d, want 1", len(roots)) } plant := roots[0] if plant.IsExpanded() { t.Fatal("plant.IsExpanded = true before Expand, want false") } if err := plant.Expand(context.Background()); err != nil { t.Fatalf("Expand: %v", err) } if !plant.IsExpanded() { t.Fatal("plant.IsExpanded = false after Expand, want true") } children := plant.Children() if len(children) != 2 { t.Fatalf("len(children) = %d, want 2", len(children)) } if children[0].Object().GetTagName() != "Area1" { t.Fatalf("children[0].TagName = %q, want Area1", children[0].Object().GetTagName()) } if !children[0].HasChildrenHint() { t.Fatal("children[0].HasChildrenHint = false, want true") } if children[1].HasChildrenHint() { t.Fatal("children[1].HasChildrenHint = true, want false") } if len(fake.browseChildrenCalls) != 2 { t.Fatalf("BrowseChildren calls = %d, want 2", len(fake.browseChildrenCalls)) } parent := fake.browseChildrenCalls[1].GetParent() parentGobj, ok := parent.(*pb.BrowseChildrenRequest_ParentGobjectId) if !ok { t.Fatalf("Parent oneof = %T, want *BrowseChildrenRequest_ParentGobjectId", parent) } if parentGobj.ParentGobjectId != 1 { t.Fatalf("ParentGobjectId = %d, want 1", parentGobj.ParentGobjectId) } } func TestGalaxyBrowseExpandIdempotentNoSecondRpc(t *testing.T) { fake := &fakeGalaxyServer{ browseChildrenReplies: []*pb.BrowseChildrenReply{ buildBrowseReply( []*pb.GalaxyObject{obj(1, "Plant", true)}, []bool{true}, 1, ), buildBrowseReply( []*pb.GalaxyObject{obj(10, "Area1", true)}, []bool{false}, 1, ), }, } client, cleanup := newGalaxyBufconnClient(t, fake) defer cleanup() roots, err := client.Browse(context.Background(), nil) if err != nil { t.Fatalf("Browse: %v", err) } plant := roots[0] if err := plant.Expand(context.Background()); err != nil { t.Fatalf("Expand #1: %v", err) } callsAfterFirst := len(fake.browseChildrenCalls) if callsAfterFirst != 2 { t.Fatalf("BrowseChildren calls after first Expand = %d, want 2", callsAfterFirst) } if err := plant.Expand(context.Background()); err != nil { t.Fatalf("Expand #2: %v", err) } if got := len(fake.browseChildrenCalls); got != callsAfterFirst { t.Fatalf("BrowseChildren calls after second Expand = %d, want %d (no extra RPC)", got, callsAfterFirst) } } func TestGalaxyBrowseExpandUnknownParentReturnsNotFoundError(t *testing.T) { fake := &fakeGalaxyServer{ browseChildrenReplies: []*pb.BrowseChildrenReply{ buildBrowseReply( []*pb.GalaxyObject{obj(1, "Plant", true)}, []bool{true}, 1, ), }, browseChildrenError: status.Error(codes.NotFound, "parent not found"), } // The first Browse() consumes the first reply; the next call (Expand) will // then hit browseChildrenError. We need the error to fire only on the second // call, so seed the reply first and let the call sequence consume them in // order. Because BrowseChildren in the fake consumes browseChildrenError // before falling through to replies, swap the strategy: keep the root reply // but have BrowseChildren return the error on the second call. We do this by // emptying the reply list after the first Browse. client, cleanup := newGalaxyBufconnClient(t, fake) defer cleanup() // First call returns the error (because browseChildrenError takes precedence). // To avoid that, clear it for the root call by performing a manual setup: we // pre-stage replies first, then set the error after the first call. Easiest: // pre-Browse() with error=nil, then set error before Expand. fake.browseChildrenError = nil roots, err := client.Browse(context.Background(), nil) if err != nil { t.Fatalf("Browse: %v", err) } if len(roots) != 1 { t.Fatalf("len(roots) = %d, want 1", len(roots)) } fake.browseChildrenError = status.Error(codes.NotFound, "parent not found") err = roots[0].Expand(context.Background()) if err == nil { t.Fatal("Expand: error = nil, want NotFound") } if status.Code(err) != codes.NotFound { t.Fatalf("status.Code = %s, want NotFound", status.Code(err)) } if roots[0].IsExpanded() { t.Fatal("roots[0].IsExpanded = true after failed Expand, want false") } } func TestGalaxyBrowseExpandMultiPageGathersAllPages(t *testing.T) { firstPage := buildBrowseReply( []*pb.GalaxyObject{obj(1, "Plant", true)}, []bool{true}, 7, ) pageA := buildBrowseReply( []*pb.GalaxyObject{obj(10, "Child1", false), obj(11, "Child2", false)}, []bool{false, false}, 7, ) pageA.NextPageToken = "7:abc:2" pageB := buildBrowseReply( []*pb.GalaxyObject{obj(12, "Child3", false)}, []bool{false}, 7, ) fake := &fakeGalaxyServer{ browseChildrenReplies: []*pb.BrowseChildrenReply{firstPage, pageA, pageB}, } client, cleanup := newGalaxyBufconnClient(t, fake) defer cleanup() roots, err := client.Browse(context.Background(), nil) if err != nil { t.Fatalf("Browse: %v", err) } if err := roots[0].Expand(context.Background()); err != nil { t.Fatalf("Expand: %v", err) } children := roots[0].Children() if len(children) != 3 { t.Fatalf("len(children) = %d, want 3", len(children)) } if len(fake.browseChildrenCalls) != 3 { t.Fatalf("BrowseChildren calls = %d, want 3", len(fake.browseChildrenCalls)) } if got := fake.browseChildrenCalls[2].GetPageToken(); got != "7:abc:2" { t.Fatalf("third call PageToken = %q, want %q", got, "7:abc:2") } } func TestGalaxyBrowseWithFilterForwardsToRequest(t *testing.T) { fake := &fakeGalaxyServer{ browseChildrenReplies: []*pb.BrowseChildrenReply{ buildBrowseReply(nil, nil, 1), }, } client, cleanup := newGalaxyBufconnClient(t, fake) defer cleanup() include := true opts := &BrowseChildrenOptions{ CategoryIds: []int32{7, 9}, TemplateChainContains: []string{"$AppObject"}, TagNameGlob: "Tank*", IncludeAttributes: &include, AlarmBearingOnly: true, HistorizedOnly: true, } if _, err := client.Browse(context.Background(), opts); err != nil { t.Fatalf("Browse: %v", err) } if len(fake.browseChildrenCalls) != 1 { t.Fatalf("BrowseChildren calls = %d, want 1", len(fake.browseChildrenCalls)) } got := fake.browseChildrenCalls[0] if want := []int32{7, 9}; len(got.GetCategoryIds()) != 2 || got.GetCategoryIds()[0] != want[0] || got.GetCategoryIds()[1] != want[1] { t.Fatalf("CategoryIds = %v, want %v", got.GetCategoryIds(), want) } if want := []string{"$AppObject"}; len(got.GetTemplateChainContains()) != 1 || got.GetTemplateChainContains()[0] != want[0] { t.Fatalf("TemplateChainContains = %v, want %v", got.GetTemplateChainContains(), want) } if got.GetTagNameGlob() != "Tank*" { t.Fatalf("TagNameGlob = %q, want %q", got.GetTagNameGlob(), "Tank*") } if !got.GetIncludeAttributes() { t.Fatal("IncludeAttributes = false, want true") } if !got.GetAlarmBearingOnly() { t.Fatal("AlarmBearingOnly = false, want true") } if !got.GetHistorizedOnly() { t.Fatal("HistorizedOnly = false, want true") } } func TestGalaxyBrowseExpandConcurrentCallersOnlyFireOneRpc(t *testing.T) { fake := &fakeGalaxyServer{ browseChildrenReplies: []*pb.BrowseChildrenReply{ // roots buildBrowseReply([]*pb.GalaxyObject{obj(1, "Plant", true)}, []bool{true}, 7), // one expand: one child buildBrowseReply([]*pb.GalaxyObject{obj(2, "Mixer", false)}, []bool{false}, 7), }, } client, cleanup := newGalaxyBufconnClient(t, fake) defer cleanup() ctx := context.Background() roots, err := client.Browse(ctx, nil) if err != nil { t.Fatalf("Browse: %v", err) } var wg sync.WaitGroup errs := make(chan error, 10) for i := 0; i < 10; i++ { wg.Add(1) go func() { defer wg.Done() errs <- roots[0].Expand(ctx) }() } wg.Wait() close(errs) for err := range errs { if err != nil { t.Fatalf("concurrent Expand: %v", err) } } if !roots[0].IsExpanded() { t.Fatal("IsExpanded() = false after 10 concurrent expands") } if got, want := len(roots[0].Children()), 1; got != want { t.Fatalf("len(children) = %d, want %d", got, want) } // 1 roots fetch + exactly 1 expand fetch. if got, want := len(fake.browseChildrenCalls), 2; got != want { t.Fatalf("RPC count = %d, want %d", got, want) } } func TestGalaxyBrowseChildrenRejectsRepeatedPageToken(t *testing.T) { // Build a reply that carries a non-empty NextPageToken so browseChildrenInner // will request a second page. Queue the same reply twice so the second response // returns the same page token, triggering the duplicate-token guard. page := buildBrowseReply( []*pb.GalaxyObject{obj(1, "Plant", true)}, []bool{true}, 1, ) page.NextPageToken = "1:abc:1" fake := &fakeGalaxyServer{ browseChildrenReplies: []*pb.BrowseChildrenReply{page, page}, } client, cleanup := newGalaxyBufconnClient(t, fake) defer cleanup() _, err := client.Browse(context.Background(), nil) if err == nil { t.Fatal("Browse: error = nil, want repeated-page-token error") } var gwErr *GatewayError if !errors.As(err, &gwErr) { t.Fatalf("error type = %T, want *GatewayError; err = %v", err, err) } }