go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/internal/ingestion/control/span_test.go (about)

     1  // Copyright 2022 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package control
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"testing"
    21  	"time"
    22  
    23  	"go.chromium.org/luci/server/span"
    24  
    25  	"go.chromium.org/luci/analysis/internal/testutil"
    26  	analysispb "go.chromium.org/luci/analysis/proto/v1"
    27  
    28  	. "github.com/smartystreets/goconvey/convey"
    29  	. "go.chromium.org/luci/common/testing/assertions"
    30  )
    31  
    32  func TestSpan(t *testing.T) {
    33  	Convey(`With Spanner Test Database`, t, func() {
    34  		ctx := testutil.SpannerTestContext(t)
    35  		Convey(`Read`, func() {
    36  			entriesToCreate := []*Entry{
    37  				NewEntry(0).WithBuildID("buildbucket-instance/1").Build(),
    38  				NewEntry(2).WithBuildID("buildbucket-instance/2").WithBuildResult(nil).Build(),
    39  				NewEntry(3).WithBuildID("buildbucket-instance/3").WithPresubmitResult(nil).Build(),
    40  				NewEntry(4).WithBuildID("buildbucket-instance/4").WithInvocationResult(nil).Build(),
    41  			}
    42  			_, err := SetEntriesForTesting(ctx, entriesToCreate...)
    43  			So(err, ShouldBeNil)
    44  
    45  			Convey(`None exist`, func() {
    46  				buildIDs := []string{"buildbucket-instance/5"}
    47  				results, err := Read(span.Single(ctx), buildIDs)
    48  				So(err, ShouldBeNil)
    49  				So(len(results), ShouldEqual, 1)
    50  				So(results[0], ShouldResembleEntry, nil)
    51  			})
    52  			Convey(`Some exist`, func() {
    53  				buildIDs := []string{
    54  					"buildbucket-instance/3",
    55  					"buildbucket-instance/4",
    56  					"buildbucket-instance/5",
    57  					"buildbucket-instance/2",
    58  					"buildbucket-instance/1",
    59  				}
    60  				results, err := Read(span.Single(ctx), buildIDs)
    61  				So(err, ShouldBeNil)
    62  				So(len(results), ShouldEqual, 5)
    63  				So(results[0], ShouldResembleEntry, entriesToCreate[2])
    64  				So(results[1], ShouldResembleEntry, entriesToCreate[3])
    65  				So(results[2], ShouldResembleEntry, nil)
    66  				So(results[3], ShouldResembleEntry, entriesToCreate[1])
    67  				So(results[4], ShouldResembleEntry, entriesToCreate[0])
    68  			})
    69  		})
    70  		Convey(`InsertOrUpdate`, func() {
    71  			testInsertOrUpdate := func(e *Entry) (time.Time, error) {
    72  				commitTime, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
    73  					return InsertOrUpdate(ctx, e)
    74  				})
    75  				return commitTime.In(time.UTC), err
    76  			}
    77  
    78  			entryToCreate := NewEntry(0).Build()
    79  
    80  			_, err := SetEntriesForTesting(ctx, entryToCreate)
    81  			So(err, ShouldBeNil)
    82  
    83  			e := NewEntry(1).Build()
    84  
    85  			Convey(`Valid`, func() {
    86  				Convey(`Insert`, func() {
    87  					commitTime, err := testInsertOrUpdate(e)
    88  					So(err, ShouldBeNil)
    89  					e.LastUpdated = commitTime
    90  
    91  					result, err := Read(span.Single(ctx), []string{e.BuildID})
    92  					So(err, ShouldBeNil)
    93  					So(len(result), ShouldEqual, 1)
    94  					So(result[0], ShouldResembleEntry, e)
    95  				})
    96  				Convey(`Update`, func() {
    97  					// Update the existing entry.
    98  					e.BuildID = entryToCreate.BuildID
    99  
   100  					commitTime, err := testInsertOrUpdate(e)
   101  					So(err, ShouldBeNil)
   102  					e.LastUpdated = commitTime
   103  
   104  					result, err := Read(span.Single(ctx), []string{e.BuildID})
   105  					So(err, ShouldBeNil)
   106  					So(len(result), ShouldEqual, 1)
   107  					So(result[0], ShouldResembleEntry, e)
   108  				})
   109  			})
   110  			Convey(`With missing Build ID`, func() {
   111  				e.BuildID = ""
   112  				_, err := testInsertOrUpdate(e)
   113  				So(err, ShouldErrLike, "build ID must be specified")
   114  			})
   115  			Convey(`With invalid Build Project`, func() {
   116  				Convey(`Missing`, func() {
   117  					e.BuildProject = ""
   118  					_, err := testInsertOrUpdate(e)
   119  					So(err, ShouldErrLike, "build project: unspecified")
   120  				})
   121  				Convey(`Invalid`, func() {
   122  					e.BuildProject = "!"
   123  					_, err := testInsertOrUpdate(e)
   124  					So(err, ShouldErrLike, `build project: must match ^[a-z0-9\-]{1,40}$`)
   125  				})
   126  			})
   127  			Convey(`With invalid Build Result`, func() {
   128  				Convey(`Missing host`, func() {
   129  					e.BuildResult.Host = ""
   130  					_, err := testInsertOrUpdate(e)
   131  					So(err, ShouldErrLike, "host must be specified")
   132  				})
   133  				Convey(`Missing id`, func() {
   134  					e.BuildResult.Id = 0
   135  					_, err := testInsertOrUpdate(e)
   136  					So(err, ShouldErrLike, "id must be specified")
   137  				})
   138  				Convey(`Missing creation time`, func() {
   139  					e.BuildResult.CreationTime = nil
   140  					_, err := testInsertOrUpdate(e)
   141  					So(err, ShouldErrLike, "build result: creation time must be specified")
   142  				})
   143  				Convey(`Missing project`, func() {
   144  					e.BuildResult.Project = ""
   145  					_, err := testInsertOrUpdate(e)
   146  					So(err, ShouldErrLike, "build result: project must be specified")
   147  				})
   148  				Convey(`Missing resultdb_host`, func() {
   149  					e.BuildResult.HasInvocation = true
   150  					e.BuildResult.ResultdbHost = ""
   151  					_, err := testInsertOrUpdate(e)
   152  					So(err, ShouldErrLike, "build result: resultdb_host must be specified if has_invocation set")
   153  				})
   154  				Convey(`Missing builder`, func() {
   155  					e.BuildResult.Builder = ""
   156  					_, err := testInsertOrUpdate(e)
   157  					So(err, ShouldErrLike, "build result: builder must be specified")
   158  				})
   159  				Convey(`Missing status`, func() {
   160  					e.BuildResult.Status = analysispb.BuildStatus_BUILD_STATUS_UNSPECIFIED
   161  					_, err := testInsertOrUpdate(e)
   162  					So(err, ShouldErrLike, "build result: build status must be specified")
   163  				})
   164  			})
   165  			Convey(`With invalid Invocation Project`, func() {
   166  				Convey(`Unspecified`, func() {
   167  					e.InvocationProject = ""
   168  					_, err := testInsertOrUpdate(e)
   169  					So(err, ShouldErrLike, `invocation project: unspecified`)
   170  				})
   171  				Convey(`Invalid`, func() {
   172  					e.InvocationProject = "!"
   173  					_, err := testInsertOrUpdate(e)
   174  					So(err, ShouldErrLike, `invocation project: must match ^[a-z0-9\-]{1,40}$`)
   175  				})
   176  			})
   177  			Convey(`With invalid Invocation Result`, func() {
   178  				Convey(`Set when HasInvocation = false`, func() {
   179  					So(e.InvocationResult, ShouldNotBeNil)
   180  					e.HasInvocation = false
   181  					_, err := testInsertOrUpdate(e)
   182  					So(err, ShouldErrLike, "invocation result must not be set unless HasInvocation is set")
   183  				})
   184  			})
   185  			Convey(`With invalid Presubmit Project`, func() {
   186  				Convey(`Unspecified`, func() {
   187  					e.PresubmitProject = ""
   188  					_, err := testInsertOrUpdate(e)
   189  					So(err, ShouldErrLike, `presubmit project: unspecified`)
   190  				})
   191  				Convey(`Invalid`, func() {
   192  					e.PresubmitProject = "!"
   193  					_, err := testInsertOrUpdate(e)
   194  					So(err, ShouldErrLike, `presubmit project: must match ^[a-z0-9\-]{1,40}$`)
   195  				})
   196  			})
   197  			Convey(`With invalid Presubmit Result`, func() {
   198  				Convey(`Set when IsPresbumit = false`, func() {
   199  					So(e.PresubmitResult, ShouldNotBeNil)
   200  					e.IsPresubmit = false
   201  					_, err := testInsertOrUpdate(e)
   202  					So(err, ShouldErrLike, "presubmit result must not be set unless IsPresubmit is set")
   203  				})
   204  				Convey(`Missing Presubmit run ID`, func() {
   205  					e.PresubmitResult.PresubmitRunId = nil
   206  					_, err := testInsertOrUpdate(e)
   207  					So(err, ShouldErrLike, "presubmit run ID must be specified")
   208  				})
   209  				Convey(`Invalid Presubmit run ID host`, func() {
   210  					e.PresubmitResult.PresubmitRunId.System = "!"
   211  					_, err := testInsertOrUpdate(e)
   212  					So(err, ShouldErrLike, "presubmit run system must be 'luci-cv'")
   213  				})
   214  				Convey(`Missing Presubmit run ID system-specific ID`, func() {
   215  					e.PresubmitResult.PresubmitRunId.Id = ""
   216  					_, err := testInsertOrUpdate(e)
   217  					So(err, ShouldErrLike, "presubmit run system-specific ID must be specified")
   218  				})
   219  				Convey(`Missing creation time`, func() {
   220  					e.PresubmitResult.CreationTime = nil
   221  					_, err := testInsertOrUpdate(e)
   222  					So(err, ShouldErrLike, "presubmit result: creation time must be specified")
   223  				})
   224  				Convey(`Missing mode`, func() {
   225  					e.PresubmitResult.Mode = analysispb.PresubmitRunMode_PRESUBMIT_RUN_MODE_UNSPECIFIED
   226  					_, err := testInsertOrUpdate(e)
   227  					So(err, ShouldErrLike, "presubmit result: mode must be specified")
   228  				})
   229  				Convey(`Missing status`, func() {
   230  					e.PresubmitResult.Status = analysispb.PresubmitRunStatus_PRESUBMIT_RUN_STATUS_UNSPECIFIED
   231  					_, err := testInsertOrUpdate(e)
   232  					So(err, ShouldErrLike, "presubmit result: status must be specified")
   233  				})
   234  			})
   235  		})
   236  		Convey(`ReadBuildToPresubmitRunJoinStatistics`, func() {
   237  			Convey(`No data`, func() {
   238  				_, err := SetEntriesForTesting(ctx, nil...)
   239  				So(err, ShouldBeNil)
   240  
   241  				results, err := ReadBuildToPresubmitRunJoinStatistics(span.Single(ctx))
   242  				So(err, ShouldBeNil)
   243  				So(results, ShouldResemble, map[string]JoinStatistics{})
   244  			})
   245  			Convey(`Data`, func() {
   246  				reference := time.Now().Add(-1 * time.Minute)
   247  				entriesToCreate := []*Entry{
   248  					// Setup following data:
   249  					// Project Alpha ("alpha") :=
   250  					//  ]-1 hour, now]: 4 presubmit builds, 2 of which without
   251  					//                  presubmit result, 1 of which without
   252  					//                  build result.
   253  					//                  1 non-presubmit build.
   254  					//  ]-36 hours, -35 hours]: 1 presubmit build,
   255  					//                          with all results.
   256  					//  ]-37 hours, -36 hours]: 1 presubmit build,
   257  					//                          with all results
   258  					//                         (should be ignored as >36 hours old).
   259  					// Project Beta ("beta") :=
   260  					//  ]-37 hours, -36 hours]: 1 presubmit build,
   261  					//                          without presubmit result.
   262  					NewEntry(0).WithBuildProject("alpha").WithBuildJoinedTime(reference).Build(),
   263  					NewEntry(1).WithBuildProject("alpha").WithBuildJoinedTime(reference).WithPresubmitResult(nil).Build(),
   264  					NewEntry(2).WithBuildProject("alpha").WithBuildJoinedTime(reference).WithPresubmitResult(nil).Build(),
   265  					NewEntry(3).WithPresubmitProject("alpha").WithPresubmitJoinedTime(reference).WithBuildResult(nil).Build(),
   266  					NewEntry(4).WithBuildProject("alpha").WithBuildJoinedTime(reference).WithIsPresubmit(false).WithPresubmitResult(nil).Build(),
   267  					NewEntry(5).WithBuildProject("alpha").WithBuildJoinedTime(reference.Add(-35 * time.Hour)).Build(),
   268  					NewEntry(6).WithBuildProject("alpha").WithBuildJoinedTime(reference.Add(-36 * time.Hour)).Build(),
   269  					NewEntry(7).WithBuildProject("beta").WithBuildJoinedTime(reference.Add(-36 * time.Hour)).WithPresubmitResult(nil).Build(),
   270  				}
   271  				_, err := SetEntriesForTesting(ctx, entriesToCreate...)
   272  				So(err, ShouldBeNil)
   273  
   274  				results, err := ReadBuildToPresubmitRunJoinStatistics(span.Single(ctx))
   275  				So(err, ShouldBeNil)
   276  
   277  				expectedAlpha := JoinStatistics{
   278  					TotalByHour:  make([]int64, 36),
   279  					JoinedByHour: make([]int64, 36),
   280  				}
   281  				expectedAlpha.TotalByHour[0] = 3
   282  				expectedAlpha.JoinedByHour[0] = 1
   283  				expectedAlpha.TotalByHour[35] = 1
   284  				expectedAlpha.JoinedByHour[35] = 1
   285  				// Only data in the last 36 hours is included, so the build
   286  				// older than 36 hours is excluded.
   287  
   288  				// Expect no entry to be returned for Project beta
   289  				// as all data is older than 36 hours.
   290  
   291  				So(results, ShouldResemble, map[string]JoinStatistics{
   292  					"alpha": expectedAlpha,
   293  				})
   294  			})
   295  		})
   296  		Convey(`ReadPresubmitToBuildJoinStatistics`, func() {
   297  			Convey(`No data`, func() {
   298  				_, err := SetEntriesForTesting(ctx, nil...)
   299  				So(err, ShouldBeNil)
   300  
   301  				results, err := ReadPresubmitToBuildJoinStatistics(span.Single(ctx))
   302  				So(err, ShouldBeNil)
   303  				So(results, ShouldResemble, map[string]JoinStatistics{})
   304  			})
   305  			Convey(`Data`, func() {
   306  				reference := time.Now().Add(-1 * time.Minute)
   307  				entriesToCreate := []*Entry{
   308  					// Setup following data:
   309  					// Project Alpha ("alpha") :=
   310  					//  ]-1 hour, now]: 4 presubmit builds, 2 of which without
   311  					//                  build result, 1 of which without
   312  					//                  presubmit result.
   313  					//                  1 non-presubmit build.
   314  					//  ]-36 hours, -35 hours]: 1 presubmit build,
   315  					//                          with all results.
   316  					//  ]-37 hours, -36 hours]: 1 presubmit build,
   317  					//                          with all results
   318  					//                         (should be ignored as >36 hours old).
   319  					// Project Beta ("beta") :=
   320  					//  ]-37 hours, -36 hours]: 1 presubmit build,
   321  					//                          without build result.
   322  					NewEntry(0).WithPresubmitProject("alpha").WithPresubmitJoinedTime(reference).Build(),
   323  					NewEntry(1).WithPresubmitProject("alpha").WithPresubmitJoinedTime(reference).WithBuildResult(nil).Build(),
   324  					NewEntry(2).WithPresubmitProject("alpha").WithPresubmitJoinedTime(reference).WithBuildResult(nil).Build(),
   325  					NewEntry(3).WithBuildProject("alpha").WithBuildJoinedTime(reference).WithPresubmitResult(nil).Build(),
   326  					NewEntry(4).WithPresubmitProject("alpha").WithPresubmitJoinedTime(reference).WithIsPresubmit(false).WithBuildResult(nil).Build(),
   327  					NewEntry(5).WithPresubmitProject("alpha").WithPresubmitJoinedTime(reference.Add(-35 * time.Hour)).Build(),
   328  					NewEntry(6).WithPresubmitProject("alpha").WithPresubmitJoinedTime(reference.Add(-36 * time.Hour)).Build(),
   329  					NewEntry(7).WithPresubmitProject("beta").WithPresubmitJoinedTime(reference.Add(-36 * time.Hour)).WithBuildResult(nil).Build(),
   330  				}
   331  				_, err := SetEntriesForTesting(ctx, entriesToCreate...)
   332  				So(err, ShouldBeNil)
   333  
   334  				results, err := ReadPresubmitToBuildJoinStatistics(span.Single(ctx))
   335  				So(err, ShouldBeNil)
   336  
   337  				expectedAlpha := JoinStatistics{
   338  					TotalByHour:  make([]int64, 36),
   339  					JoinedByHour: make([]int64, 36),
   340  				}
   341  				expectedAlpha.TotalByHour[0] = 3
   342  				expectedAlpha.JoinedByHour[0] = 1
   343  				expectedAlpha.TotalByHour[35] = 1
   344  				expectedAlpha.JoinedByHour[35] = 1
   345  				// Only data in the last 36 hours is included, so the build
   346  				// older than 36 hours is excluded.
   347  
   348  				// Expect no entry to be returned for Project beta
   349  				// as all data is older than 36 hours.
   350  
   351  				So(results, ShouldResemble, map[string]JoinStatistics{
   352  					"alpha": expectedAlpha,
   353  				})
   354  			})
   355  		})
   356  		Convey(`ReadBuildToInvocationJoinStatistics`, func() {
   357  			Convey(`No data`, func() {
   358  				_, err := SetEntriesForTesting(ctx, nil...)
   359  				So(err, ShouldBeNil)
   360  
   361  				results, err := ReadBuildToInvocationJoinStatistics(span.Single(ctx))
   362  				So(err, ShouldBeNil)
   363  				So(results, ShouldResemble, map[string]JoinStatistics{})
   364  			})
   365  			Convey(`Data`, func() {
   366  				reference := time.Now().Add(-1 * time.Minute)
   367  				entriesToCreate := []*Entry{
   368  					// Setup following data:
   369  					// Project Alpha ("alpha") :=
   370  					//  ]-1 hour, now]: 4 builds /w invocation, 2 of which without
   371  					//                  invocation result, 1 of which without
   372  					//                  build result.
   373  					//                  1 build w/o invocation.
   374  					//  ]-36 hours, -35 hours]: 1 build /w invocation,
   375  					//                          with all results.
   376  					//  ]-37 hours, -36 hours]: 1 build /w invocation,
   377  					//                          with all results
   378  					//                         (should be ignored as >36 hours old).
   379  					// Project Beta ("beta") :=
   380  					//  ]-37 hours, -36 hours]: 1 build /w invocation,
   381  					//                          without invocation result.
   382  					NewEntry(0).WithBuildProject("alpha").WithBuildJoinedTime(reference).Build(),
   383  					NewEntry(1).WithBuildProject("alpha").WithBuildJoinedTime(reference).WithInvocationResult(nil).Build(),
   384  					NewEntry(2).WithBuildProject("alpha").WithBuildJoinedTime(reference).WithInvocationResult(nil).Build(),
   385  					NewEntry(3).WithInvocationProject("alpha").WithInvocationJoinedTime(reference).WithBuildResult(nil).Build(),
   386  					NewEntry(4).WithBuildProject("alpha").WithBuildJoinedTime(reference).WithHasInvocation(false).WithInvocationResult(nil).Build(),
   387  					NewEntry(5).WithBuildProject("alpha").WithBuildJoinedTime(reference.Add(-35 * time.Hour)).Build(),
   388  					NewEntry(6).WithBuildProject("alpha").WithBuildJoinedTime(reference.Add(-36 * time.Hour)).Build(),
   389  					NewEntry(7).WithBuildProject("beta").WithBuildJoinedTime(reference.Add(-36 * time.Hour)).WithInvocationResult(nil).Build(),
   390  				}
   391  				_, err := SetEntriesForTesting(ctx, entriesToCreate...)
   392  				So(err, ShouldBeNil)
   393  
   394  				results, err := ReadBuildToInvocationJoinStatistics(span.Single(ctx))
   395  				So(err, ShouldBeNil)
   396  
   397  				expectedAlpha := JoinStatistics{
   398  					TotalByHour:  make([]int64, 36),
   399  					JoinedByHour: make([]int64, 36),
   400  				}
   401  				expectedAlpha.TotalByHour[0] = 3
   402  				expectedAlpha.JoinedByHour[0] = 1
   403  				expectedAlpha.TotalByHour[35] = 1
   404  				expectedAlpha.JoinedByHour[35] = 1
   405  				// Only data in the last 36 hours is included, so the build
   406  				// older than 36 hours is excluded.
   407  
   408  				// Expect no entry to be returned for Project beta
   409  				// as all data is older than 36 hours.
   410  
   411  				So(results, ShouldResemble, map[string]JoinStatistics{
   412  					"alpha": expectedAlpha,
   413  				})
   414  			})
   415  		})
   416  		Convey(`ReadInvocationToBuildJoinStatistics`, func() {
   417  			Convey(`No data`, func() {
   418  				_, err := SetEntriesForTesting(ctx, nil...)
   419  				So(err, ShouldBeNil)
   420  
   421  				results, err := ReadInvocationToBuildJoinStatistics(span.Single(ctx))
   422  				So(err, ShouldBeNil)
   423  				So(results, ShouldResemble, map[string]JoinStatistics{})
   424  			})
   425  			Convey(`Data`, func() {
   426  				reference := time.Now().Add(-1 * time.Minute)
   427  				entriesToCreate := []*Entry{
   428  					// Setup following data:
   429  					// Project Alpha ("alpha") :=
   430  					//  ]-1 hour, now]: 4 builds /w invocation, 2 of which without
   431  					//                  build result, 1 of which without
   432  					//                  invocation result.
   433  					//                  1 build w/o invocation.
   434  					//  ]-36 hours, -35 hours]: 1 build /w invocation,
   435  					//                          with all results.
   436  					//  ]-37 hours, -36 hours]: 1 build /w invocation,
   437  					//                          with all results
   438  					//                         (should be ignored as >36 hours old).
   439  					// Project Beta ("beta") :=
   440  					//  ]-37 hours, -36 hours]: 1 build /w invocation,
   441  					//                          without build result.
   442  					NewEntry(0).WithInvocationProject("alpha").WithInvocationJoinedTime(reference).Build(),
   443  					NewEntry(1).WithInvocationProject("alpha").WithInvocationJoinedTime(reference).WithBuildResult(nil).Build(),
   444  					NewEntry(2).WithInvocationProject("alpha").WithInvocationJoinedTime(reference).WithBuildResult(nil).Build(),
   445  					NewEntry(3).WithBuildProject("alpha").WithBuildJoinedTime(reference).WithInvocationResult(nil).Build(),
   446  					NewEntry(4).WithInvocationProject("alpha").WithInvocationJoinedTime(reference).WithHasInvocation(false).WithBuildResult(nil).Build(),
   447  					NewEntry(5).WithInvocationProject("alpha").WithInvocationJoinedTime(reference.Add(-35 * time.Hour)).Build(),
   448  					NewEntry(6).WithInvocationProject("alpha").WithInvocationJoinedTime(reference.Add(-36 * time.Hour)).Build(),
   449  					NewEntry(7).WithInvocationProject("beta").WithInvocationJoinedTime(reference.Add(-36 * time.Hour)).WithBuildResult(nil).Build(),
   450  				}
   451  				_, err := SetEntriesForTesting(ctx, entriesToCreate...)
   452  				So(err, ShouldBeNil)
   453  
   454  				results, err := ReadInvocationToBuildJoinStatistics(span.Single(ctx))
   455  				So(err, ShouldBeNil)
   456  
   457  				expectedAlpha := JoinStatistics{
   458  					TotalByHour:  make([]int64, 36),
   459  					JoinedByHour: make([]int64, 36),
   460  				}
   461  				expectedAlpha.TotalByHour[0] = 3
   462  				expectedAlpha.JoinedByHour[0] = 1
   463  				expectedAlpha.TotalByHour[35] = 1
   464  				expectedAlpha.JoinedByHour[35] = 1
   465  				// Only data in the last 36 hours is included, so the build
   466  				// older than 36 hours is excluded.
   467  
   468  				// Expect no entry to be returned for Project beta
   469  				// as all data is older than 36 hours.
   470  
   471  				So(results, ShouldResemble, map[string]JoinStatistics{
   472  					"alpha": expectedAlpha,
   473  				})
   474  			})
   475  		})
   476  	})
   477  }
   478  
   479  func ShouldResembleEntry(actual any, expected ...any) string {
   480  	if len(expected) != 1 {
   481  		return fmt.Sprintf("ShouldResembleEntry expects 1 value, got %d", len(expected))
   482  	}
   483  	exp := expected[0]
   484  	if exp == nil {
   485  		return ShouldBeNil(actual)
   486  	}
   487  
   488  	a, ok := actual.(*Entry)
   489  	if !ok {
   490  		return "actual should be of type *Entry"
   491  	}
   492  	e, ok := exp.(*Entry)
   493  	if !ok {
   494  		return "expected value should be of type *Entry"
   495  	}
   496  
   497  	// Check equality of non-proto fields.
   498  	a.BuildResult = nil
   499  	a.PresubmitResult = nil
   500  	e.BuildResult = nil
   501  	e.PresubmitResult = nil
   502  	if msg := ShouldResemble(a, e); msg != "" {
   503  		return msg
   504  	}
   505  
   506  	// Check equality of proto fields.
   507  	if msg := ShouldResembleProto(a.BuildResult, e.BuildResult); msg != "" {
   508  		return msg
   509  	}
   510  	if msg := ShouldResembleProto(a.PresubmitResult, e.PresubmitResult); msg != "" {
   511  		return msg
   512  	}
   513  	return ""
   514  }