blob: 3280d965dfb112dbdd0e6feda929c77e98aac6ab [file] [log] [blame]
package fscommentstore
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.skia.org/infra/go/deepequal"
"go.skia.org/infra/go/firestore"
"go.skia.org/infra/go/paramtools"
"go.skia.org/infra/go/testutils/unittest"
"go.skia.org/infra/golden/go/comment/trace"
data "go.skia.org/infra/golden/go/testutils/data_three_devices"
"go.skia.org/infra/golden/go/types"
)
func TestCreateAndListComments_CommentsCreatedOutOfOrder_ReturnsOrderedListOfComments(t *testing.T) {
unittest.LargeTest(t)
c, ctx, cleanup := makeTestFirestoreClient(t)
defer cleanup()
f := newEmptyStore(ctx, t, c)
xtc := makeSortedTraceComments()
// Add them in a not-sorted order to make sure ListComments sorts them.
createAndRequireNoError(ctx, t, f, xtc[2])
createAndRequireNoError(ctx, t, f, xtc[0])
createAndRequireNoError(ctx, t, f, xtc[3])
createAndRequireNoError(ctx, t, f, xtc[1])
requireCurrentListMatches(t, ctx, f, makeSortedTraceComments()...)
}
func TestCreateAndListComments_MutatingQueryToMatchMapDoesNotImpactCache(t *testing.T) {
unittest.LargeTest(t)
c, ctx, cleanup := makeTestFirestoreClient(t)
defer cleanup()
f := newEmptyStore(ctx, t, c)
comment := makeSortedTraceComments()[0]
createAndRequireNoError(ctx, t, f, comment)
// Being antagonistic, we modify the comment we passed in earlier. If care was not taken, the
// cache could share the QueryToMatch map and this edit would change the cache (which is bad).
comment.QueryToMatch["boo"] = []string{"hiss"}
// The cached/returned version should still match the original data, proving our mutation did
// not impact it.
requireCurrentListMatches(t, ctx, f, makeSortedTraceComments()[0])
listed, err := f.ListComments(ctx)
require.NoError(t, err)
listed[0].QueryToMatch["alpha"] = []string{"beta"}
// The cached/returned version should still match the original data, proving our mutation of
// the returned value did not impact the cache.
requireCurrentListMatches(t, ctx, f, makeSortedTraceComments()[0])
}
func TestCreateAndDeleteComments_Success(t *testing.T) {
unittest.LargeTest(t)
c, ctx, cleanup := makeTestFirestoreClient(t)
defer cleanup()
f := newEmptyStore(ctx, t, c)
xtc := makeSortedTraceComments()
// Add 0, 1, 2, 2, 2, 2, 3 (there are 3 extra of index 2)
createAndRequireNoError(ctx, t, f, xtc[0])
createAndRequireNoError(ctx, t, f, xtc[1])
// We are adding duplicate comments out of convenience, not to check any special logic.
createAndRequireNoError(ctx, t, f, xtc[2])
createAndRequireNoError(ctx, t, f, xtc[2])
createAndRequireNoError(ctx, t, f, xtc[2])
createAndRequireNoError(ctx, t, f, xtc[2])
createAndRequireNoError(ctx, t, f, xtc[3])
// Wait until those 7 comments are in the list
require.Eventually(t, func() bool {
actualComments, _ := f.ListComments(ctx)
return len(actualComments) == 7
}, 5*time.Second, 200*time.Millisecond)
// Re-query the comments to make sure none got dropped or added unexpectedly.
actualComments, err := f.ListComments(ctx)
require.NoError(t, err)
require.Len(t, actualComments, 7) // should still have 7 elements in the list
// Delete the duplicated xtc[2] comments. Due to the fact that ListComments returns them in
// sorted order, we can directly pick out the IDs we want to remove.
err = f.DeleteComment(ctx, actualComments[3].ID)
require.NoError(t, err)
err = f.DeleteComment(ctx, actualComments[4].ID)
require.NoError(t, err)
err = f.DeleteComment(ctx, actualComments[5].ID)
require.NoError(t, err)
requireCurrentListMatches(t, ctx, f, makeSortedTraceComments()...)
}
func TestDeleteComment_NonExistentComment_Error(t *testing.T) {
unittest.LargeTest(t)
c, ctx, cleanup := makeTestFirestoreClient(t)
defer cleanup()
f := New(ctx, c)
err := f.DeleteComment(ctx, "Not in there")
require.NoError(t, err)
}
func TestDeleteComment_EmptyComment_Error(t *testing.T) {
unittest.LargeTest(t)
c, ctx, cleanup := makeTestFirestoreClient(t)
defer cleanup()
f := New(ctx, c)
err := f.DeleteComment(ctx, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "empty")
}
// TestCreateAndUpdateComment_UpdateOnlyUpdatableFields makes sure that when we update a comment,
// we only update the fields that are mutable. That is, the CreatedBy and CreatedTS fields
// should stay the same, and everything else can take on the new values.
func TestCreateAndUpdateComment_UpdateOnlyUpdatableFields(t *testing.T) {
unittest.LargeTest(t)
c, ctx, cleanup := makeTestFirestoreClient(t)
defer cleanup()
f := newEmptyStore(ctx, t, c)
xtc := makeSortedTraceComments()
createAndRequireNoError(ctx, t, f, xtc[0])
// Wait until that comment is in the list
require.Eventually(t, func() bool {
actualComments, _ := f.ListComments(ctx)
return len(actualComments) == 1
}, 5*time.Second, 200*time.Millisecond)
// Edit all fields of the comment, including those that are should not be modified by the store
// (CreatedBy and CreatedTS).
actualComments, err := f.ListComments(ctx)
require.NoError(t, err)
toUpdateID := actualComments[0].ID
editedComment := xtc[3]
editedComment.ID = toUpdateID
require.NoError(t, f.UpdateComment(ctx, editedComment))
expectedUpdatedComment := trace.Comment{
// These fields are from the original xtc[0] and should be immutable.
CreatedBy: xtc[0].CreatedBy,
CreatedTS: xtc[0].CreatedTS,
// These fields were overwritten from xtc[3]
UpdatedBy: xtc[3].UpdatedBy,
UpdatedTS: xtc[3].UpdatedTS,
Comment: xtc[3].Comment,
QueryToMatch: xtc[3].QueryToMatch,
}
requireCurrentListMatches(t, ctx, f, expectedUpdatedComment)
}
func TestUpdateComment_NoExistentComment_Error(t *testing.T) {
unittest.LargeTest(t)
c, ctx, cleanup := makeTestFirestoreClient(t)
defer cleanup()
f := New(ctx, c)
tc := makeSortedTraceComments()[0]
tc.ID = "whoops"
err := f.UpdateComment(ctx, tc)
require.Error(t, err)
assert.Contains(t, err.Error(), "before updating")
}
func TestUpdateComment_EmptyComment_Error(t *testing.T) {
unittest.LargeTest(t)
c, ctx, cleanup := makeTestFirestoreClient(t)
defer cleanup()
f := New(ctx, c)
err := f.UpdateComment(ctx, trace.Comment{})
require.Error(t, err)
assert.Contains(t, err.Error(), "empty")
}
func createAndRequireNoError(ctx context.Context, t *testing.T, f *StoreImpl, comment trace.Comment) {
_, err := f.CreateComment(ctx, comment)
require.NoError(t, err)
}
func newEmptyStore(ctx context.Context, t *testing.T, c *firestore.Client) *StoreImpl {
f := New(ctx, c)
empty, err := f.ListComments(ctx)
require.NoError(t, err)
require.Empty(t, empty)
return f
}
// compareTraceCommentsIgnoringIDs returns true if the two lists of comments match (disregarding the
// ID). ID is ignored because it is nondeterministic.
func compareTraceCommentsIgnoringIDs(first, second []trace.Comment) bool {
if len(first) != len(second) {
return false
}
for i := range first {
r1, r2 := first[i], second[i]
r1.ID = ""
r2.ID = ""
if !deepequal.DeepEqual(r1, r2) {
return false
}
}
return true
}
// requireCurrentListMatches either returns because the content in the given store
// matches makeSortedTraceComments() or it panics because it does not match.
func requireCurrentListMatches(t *testing.T, ctx context.Context, f *StoreImpl, expected ...trace.Comment) {
// List uses a query snapshot, which is not synchronous, so we might have to query a few times
// before everything syncs up.
require.Eventually(t, func() bool {
actualComments, err := f.ListComments(ctx)
assert.NoError(t, err)
return compareTraceCommentsIgnoringIDs(actualComments, expected)
}, 5*time.Second, 200*time.Millisecond)
}
// makeSortedTraceComments returns 4 traces with arbitrary, but valid data. The QueryToMatch fields are
// normalized (i.e. the values are sorted) and the slice is sorted low to high by UpdatedTS.
func makeSortedTraceComments() []trace.Comment {
return []trace.Comment{
{
CreatedBy: "zulu@example.com",
UpdatedBy: "zulu@example.com",
CreatedTS: time.Date(2020, time.February, 19, 18, 17, 16, 0, time.UTC),
UpdatedTS: time.Date(2020, time.February, 19, 18, 17, 16, 0, time.UTC),
Comment: "All bullhead devices draw upside down",
QueryToMatch: paramtools.ParamSet{
"device": []string{data.BullheadDevice},
},
},
{
CreatedBy: "yankee@example.com",
UpdatedBy: "xray@example.com",
CreatedTS: time.Date(2020, time.February, 2, 18, 17, 16, 0, time.UTC),
UpdatedTS: time.Date(2020, time.February, 20, 18, 17, 16, 0, time.UTC),
Comment: "Watch pixel 0,4 to make sure it's not purple",
QueryToMatch: paramtools.ParamSet{
types.PrimaryKeyField: []string{string(data.AlphaTest)},
},
},
{
CreatedBy: "victor@example.com",
UpdatedBy: "victor@example.com",
CreatedTS: time.Date(2020, time.February, 22, 18, 17, 16, 0, time.UTC),
UpdatedTS: time.Date(2020, time.February, 22, 18, 17, 16, 0, time.UTC),
Comment: "This test should be ABGR instead of RGBA on angler and bullhead due to hysterical raisins",
QueryToMatch: paramtools.ParamSet{
"device": []string{data.AnglerDevice, data.BullheadDevice},
types.PrimaryKeyField: []string{string(data.BetaTest)},
},
},
{
CreatedBy: "uniform@example.com",
UpdatedBy: "uniform@example.com",
CreatedTS: time.Date(2020, time.February, 26, 26, 26, 26, 0, time.UTC),
UpdatedTS: time.Date(2020, time.February, 26, 26, 26, 26, 0, time.UTC),
Comment: "On Wednesdays, this device draws pink",
QueryToMatch: paramtools.ParamSet{
"device": []string{"This device does not exist"},
},
},
}
}
// makeTestFirestoreClient returns a firestore.Client and a context.Context. When the third return
// value is called, the Context will be cancelled and the Client will be cleaned up.
func makeTestFirestoreClient(t *testing.T) (*firestore.Client, context.Context, func()) {
ctx, cancel := context.WithCancel(context.Background())
c, cleanup := firestore.NewClientForTesting(ctx, t)
return c, ctx, func() {
cancel()
cleanup()
}
}