blob: 61c07d56059cf8714ff1b433aedc1fa34049e269 [file] [log] [blame]
package fs_expstore
import (
"context"
"fmt"
"strconv"
"sync"
"testing"
"time"
"github.com/google/uuid"
assert "github.com/stretchr/testify/require"
"go.skia.org/infra/go/eventbus/mocks"
"go.skia.org/infra/go/firestore"
"go.skia.org/infra/go/testutils/unittest"
"go.skia.org/infra/golden/go/expstorage"
data "go.skia.org/infra/golden/go/testutils/data_three_devices"
"go.skia.org/infra/golden/go/types"
)
// TODO(kjlubick): These tests are marked as manual because the
// Firestore Emulator is not yet on the bots, due to some more complicated
// setup (e.g. chmod)
// TestGetExpectations writes some changes and then reads back the
// aggregated results.
func TestGetExpectations(t *testing.T) {
unittest.ManualTest(t)
unittest.RequiresFirestoreEmulator(t)
c := getTestFirestoreInstance(t)
f := New(c, nil, ReadWrite)
// Brand new instance should have no expectations
e, err := f.Get()
assert.NoError(t, err)
assert.Equal(t, types.Expectations{}, e)
ctx := context.Background()
err = f.AddChange(ctx, types.Expectations{
data.AlphaTest: {
data.AlphaUntriaged1Digest: types.POSITIVE,
data.AlphaGood1Digest: types.POSITIVE,
},
}, userOne)
assert.NoError(t, err)
err = f.AddChange(ctx, types.Expectations{
data.AlphaTest: {
data.AlphaBad1Digest: types.NEGATIVE,
data.AlphaUntriaged1Digest: types.UNTRIAGED, // overwrites previous
},
data.BetaTest: {
data.BetaGood1Digest: types.POSITIVE,
},
}, userTwo)
assert.NoError(t, err)
expected := types.Expectations{
data.AlphaTest: {
data.AlphaGood1Digest: types.POSITIVE,
data.AlphaBad1Digest: types.NEGATIVE,
data.AlphaUntriaged1Digest: types.UNTRIAGED,
},
data.BetaTest: {
data.BetaGood1Digest: types.POSITIVE,
},
}
e, err = f.Get()
assert.NoError(t, err)
assert.Equal(t, expected, e)
// Make sure that if we create a new view, we can read the results
// from the table to make the expectations
fr := New(c, nil, ReadOnly)
e, err = fr.Get()
assert.NoError(t, err)
assert.Equal(t, expected, e)
}
// TestGetExpectationsRace writes a bunch of data from many go routines
// in an effort to catch any race conditions in the caching layer.
func TestGetExpectationsRace(t *testing.T) {
unittest.ManualTest(t)
unittest.RequiresFirestoreEmulator(t)
c := getTestFirestoreInstance(t)
f := New(c, nil, ReadWrite)
type entry struct {
Grouping types.TestName
Digest types.Digest
Label types.Label
}
entries := []entry{
{
Grouping: data.AlphaTest,
Digest: data.AlphaUntriaged1Digest,
Label: types.UNTRIAGED,
},
{
Grouping: data.AlphaTest,
Digest: data.AlphaBad1Digest,
Label: types.NEGATIVE,
},
{
Grouping: data.AlphaTest,
Digest: data.AlphaGood1Digest,
Label: types.POSITIVE,
},
{
Grouping: data.BetaTest,
Digest: data.BetaGood1Digest,
Label: types.POSITIVE,
},
{
Grouping: data.BetaTest,
Digest: data.BetaUntriaged1Digest,
Label: types.UNTRIAGED,
},
}
ctx := context.Background()
wg := sync.WaitGroup{}
for i := 0; i < 50; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
e := entries[i%len(entries)]
err := f.AddChange(ctx, types.Expectations{
e.Grouping: {
e.Digest: e.Label,
},
}, userOne)
assert.NoError(t, err)
}(i)
// Make sure we can read and write w/o races
if i%5 == 0 {
_, err := f.Get()
assert.NoError(t, err)
}
}
wg.Wait()
e, err := f.Get()
assert.NoError(t, err)
assert.Equal(t, types.Expectations{
data.AlphaTest: {
data.AlphaGood1Digest: types.POSITIVE,
data.AlphaBad1Digest: types.NEGATIVE,
data.AlphaUntriaged1Digest: types.UNTRIAGED,
},
data.BetaTest: {
data.BetaGood1Digest: types.POSITIVE,
data.BetaUntriaged1Digest: types.UNTRIAGED,
},
}, e)
}
// TestGetExpectationsBig writes 32^2=1024 entries
// to test the batch writing.
func TestGetExpectationsBig(t *testing.T) {
unittest.ManualTest(t)
unittest.RequiresFirestoreEmulator(t)
c := getTestFirestoreInstance(t)
f := New(c, nil, ReadWrite)
// Write the expectations in two, non-overlapping blocks.
exp1 := makeBigExpectations(0, 16)
exp2 := makeBigExpectations(16, 32)
expected := exp1.DeepCopy()
expected.MergeExpectations(exp2)
ctx := context.Background()
wg := sync.WaitGroup{}
// Write them concurrently to test for races.
wg.Add(2)
go func() {
defer wg.Done()
err := f.AddChange(ctx, exp1, userOne)
assert.NoError(t, err)
}()
go func() {
defer wg.Done()
err := f.AddChange(ctx, exp2, userTwo)
assert.NoError(t, err)
}()
wg.Wait()
e, err := f.Get()
assert.NoError(t, err)
assert.Equal(t, expected, e)
// Make sure that if we create a new view, we can read the results
// from the table to make the expectations
fr := New(c, nil, ReadOnly)
e, err = fr.Get()
assert.NoError(t, err)
assert.Equal(t, expected, e)
}
// TestReadOnly ensures a read-only instance fails to write data.
func TestReadOnly(t *testing.T) {
unittest.SmallTest(t)
f := New(nil, nil, ReadOnly)
err := f.AddChange(context.Background(), types.Expectations{
data.AlphaTest: {
data.AlphaGood1Digest: types.POSITIVE,
},
}, userOne)
assert.Error(t, err)
assert.Contains(t, err.Error(), "read-only")
}
// TestQueryLog tests that we can query logs at a given place
func TestQueryLog(t *testing.T) {
unittest.ManualTest(t)
unittest.RequiresFirestoreEmulator(t)
c := getTestFirestoreInstance(t)
f := New(c, nil, ReadWrite)
fillWith4Entries(t, f)
ctx := context.Background()
entries, n, err := f.QueryLog(ctx, 0, 100, false)
assert.NoError(t, err)
assert.Equal(t, 4, n) // 4 operations
now := time.Now()
normalizeEntries(t, now, entries)
assert.Equal(t, []expstorage.TriageLogEntry{
{
ID: "was_random_0",
Name: userTwo,
TS: now.Unix(),
ChangeCount: 2,
Details: nil,
},
{
ID: "was_random_1",
Name: userOne,
TS: now.Unix(),
ChangeCount: 1,
Details: nil,
},
{
ID: "was_random_2",
Name: userTwo,
TS: now.Unix(),
ChangeCount: 1,
Details: nil,
},
{
ID: "was_random_3",
Name: userOne,
TS: now.Unix(),
ChangeCount: 1,
Details: nil,
},
}, entries)
entries, n, err = f.QueryLog(ctx, 1, 2, false)
assert.NoError(t, err)
assert.Equal(t, 2, n)
normalizeEntries(t, now, entries)
assert.Equal(t, []expstorage.TriageLogEntry{
{
ID: "was_random_0",
Name: userOne,
TS: now.Unix(),
ChangeCount: 1,
Details: nil,
},
{
ID: "was_random_1",
Name: userTwo,
TS: now.Unix(),
ChangeCount: 1,
Details: nil,
},
}, entries)
// Make sure we can handle an invalid offset
entries, n, err = f.QueryLog(ctx, 500, 100, false)
assert.NoError(t, err)
assert.Equal(t, 0, n)
assert.Nil(t, entries)
}
// TestQueryLogDetails checks that the details are filled in when requested.
func TestQueryLogDetails(t *testing.T) {
unittest.ManualTest(t)
unittest.RequiresFirestoreEmulator(t)
c := getTestFirestoreInstance(t)
f := New(c, nil, ReadWrite)
fillWith4Entries(t, f)
ctx := context.Background()
entries, n, err := f.QueryLog(ctx, 0, 100, true)
assert.NoError(t, err)
assert.Equal(t, 4, n) // 4 operations
assert.Equal(t, []expstorage.TriageDetail{
{
TestName: data.AlphaTest,
Digest: data.AlphaBad1Digest,
Label: types.NEGATIVE.String(),
},
{
TestName: data.BetaTest,
Digest: data.BetaUntriaged1Digest,
Label: types.UNTRIAGED.String(),
},
}, entries[0].Details)
assert.Equal(t, []expstorage.TriageDetail{
{
TestName: data.BetaTest,
Digest: data.BetaGood1Digest,
Label: types.POSITIVE.String(),
},
}, entries[1].Details)
assert.Equal(t, []expstorage.TriageDetail{
{
TestName: data.AlphaTest,
Digest: data.AlphaGood1Digest,
Label: types.POSITIVE.String(),
},
}, entries[2].Details)
assert.Equal(t, []expstorage.TriageDetail{
{
TestName: data.AlphaTest,
Digest: data.AlphaGood1Digest,
Label: types.NEGATIVE.String(),
},
}, entries[3].Details)
}
// TestUndoChangeSunnyDay checks undoing entries that exist.
func TestUndoChangeSunnyDay(t *testing.T) {
unittest.ManualTest(t)
unittest.RequiresFirestoreEmulator(t)
c := getTestFirestoreInstance(t)
f := New(c, nil, ReadWrite)
fillWith4Entries(t, f)
ctx := context.Background()
entries, n, err := f.QueryLog(ctx, 0, 4, false)
assert.NoError(t, err)
assert.Equal(t, 4, n)
exp, err := f.UndoChange(ctx, entries[0].ID, userOne)
assert.NoError(t, err)
assert.Equal(t, types.Expectations{
data.AlphaTest: {
data.AlphaBad1Digest: types.UNTRIAGED,
},
data.BetaTest: {
data.BetaUntriaged1Digest: types.UNTRIAGED,
},
}, exp)
exp, err = f.UndoChange(ctx, entries[2].ID, userOne)
assert.NoError(t, err)
assert.Equal(t, types.Expectations{
data.AlphaTest: {
data.AlphaGood1Digest: types.NEGATIVE,
},
}, exp)
expected := types.Expectations{
data.AlphaTest: {
data.AlphaGood1Digest: types.NEGATIVE,
data.AlphaBad1Digest: types.UNTRIAGED,
},
data.BetaTest: {
data.BetaGood1Digest: types.POSITIVE,
data.BetaUntriaged1Digest: types.UNTRIAGED,
},
}
// Check that the undone items were applied
exp, err = f.Get()
assert.NoError(t, err)
assert.Equal(t, expected, exp)
// Make sure that if we create a new view, we can read the results
// from the table to make the expectations
fr := New(c, nil, ReadOnly)
exp, err = fr.Get()
assert.NoError(t, err)
assert.Equal(t, expected, exp)
}
// TestUndoChangeNoExist checks undoing an entry that does not exist.
func TestUndoChangeNoExist(t *testing.T) {
unittest.ManualTest(t)
unittest.RequiresFirestoreEmulator(t)
c := getTestFirestoreInstance(t)
f := New(c, nil, ReadWrite)
_, err := f.UndoChange(context.Background(), "doesnotexist", "userTwo")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not find change")
}
// TestEventBusAddMaster makes sure proper eventbus signals are sent
// when changes are made to the master branch.
func TestEventBusAddMaster(t *testing.T) {
unittest.ManualTest(t)
unittest.RequiresFirestoreEmulator(t)
meb := &mocks.EventBus{}
defer meb.AssertExpectations(t)
c := getTestFirestoreInstance(t)
f := New(c, meb, ReadWrite)
change1 := types.Expectations{
data.AlphaTest: {
data.AlphaGood1Digest: types.POSITIVE,
},
}
change2 := types.Expectations{
data.AlphaTest: {
data.AlphaBad1Digest: types.NEGATIVE,
},
data.BetaTest: {
data.BetaGood1Digest: types.POSITIVE,
},
}
meb.On("Publish", expstorage.EV_EXPSTORAGE_CHANGED, &expstorage.EventExpectationChange{
TestChanges: change1,
IssueID: expstorage.MasterBranch,
}, /*global=*/ true).Once()
meb.On("Publish", expstorage.EV_EXPSTORAGE_CHANGED, &expstorage.EventExpectationChange{
TestChanges: change2,
IssueID: expstorage.MasterBranch,
}, /*global=*/ true).Once()
ctx := context.Background()
assert.NoError(t, f.AddChange(ctx, change1, userOne))
assert.NoError(t, f.AddChange(ctx, change2, userTwo))
}
// TestEventBusAddIssue makes sure proper eventbus signals are sent
// when changes are made to an IssueExpectations.
func TestEventBusAddIssue(t *testing.T) {
unittest.ManualTest(t)
unittest.RequiresFirestoreEmulator(t)
meb := &mocks.EventBus{}
defer meb.AssertExpectations(t)
c := getTestFirestoreInstance(t)
e := New(c, meb, ReadWrite)
issue := int64(117)
f := e.ForIssue(issue) // arbitrary issue
change1 := types.Expectations{
data.AlphaTest: {
data.AlphaGood1Digest: types.POSITIVE,
},
}
change2 := types.Expectations{
data.AlphaTest: {
data.AlphaBad1Digest: types.NEGATIVE,
},
data.BetaTest: {
data.BetaGood1Digest: types.POSITIVE,
},
}
meb.On("Publish", expstorage.EV_TRYJOB_EXP_CHANGED, &expstorage.EventExpectationChange{
TestChanges: change1,
IssueID: issue,
}, /*global=*/ false).Once()
meb.On("Publish", expstorage.EV_TRYJOB_EXP_CHANGED, &expstorage.EventExpectationChange{
TestChanges: change2,
IssueID: issue,
}, /*global=*/ false).Once()
ctx := context.Background()
assert.NoError(t, f.AddChange(ctx, change1, userOne))
assert.NoError(t, f.AddChange(ctx, change2, userTwo))
}
// TestEventBusUndo tests that eventbus signals are properly sent during Undo.
func TestEventBusUndo(t *testing.T) {
unittest.ManualTest(t)
unittest.RequiresFirestoreEmulator(t)
meb := &mocks.EventBus{}
defer meb.AssertExpectations(t)
c := getTestFirestoreInstance(t)
f := New(c, meb, ReadWrite)
change := types.Expectations{
data.AlphaTest: {
data.AlphaGood1Digest: types.NEGATIVE,
},
}
expectedUndo := types.Expectations{
data.AlphaTest: {
data.AlphaGood1Digest: types.UNTRIAGED,
},
}
meb.On("Publish", expstorage.EV_EXPSTORAGE_CHANGED, &expstorage.EventExpectationChange{
TestChanges: change,
IssueID: expstorage.MasterBranch,
}, /*global=*/ true).Once()
meb.On("Publish", expstorage.EV_EXPSTORAGE_CHANGED, &expstorage.EventExpectationChange{
TestChanges: expectedUndo,
IssueID: expstorage.MasterBranch,
}, /*global=*/ true).Once()
ctx := context.Background()
assert.NoError(t, f.AddChange(ctx, change, userOne))
entries, n, err := f.QueryLog(ctx, 0, 1, false)
assert.NoError(t, err)
assert.Equal(t, 1, n)
exp, err := f.UndoChange(ctx, entries[0].ID, userOne)
assert.NoError(t, err)
assert.Equal(t, expectedUndo, exp)
}
// TestIssueExpectationsAddGet tests the separation of the MasterExpectations
// and the IssueExpectations. It starts with a shared history, then
// adds some expectations to both, before asserting that they are properly dealt
// with. Specifically, the IssueExpectations should be applied as a delta to
// the MasterExpectations.
func TestIssueExpectationsAddGet(t *testing.T) {
unittest.ManualTest(t)
unittest.RequiresFirestoreEmulator(t)
c := getTestFirestoreInstance(t)
mb := New(c, nil, ReadWrite)
ctx := context.Background()
assert.NoError(t, mb.AddChange(ctx, types.Expectations{
data.AlphaTest: {
data.AlphaGood1Digest: types.NEGATIVE,
},
}, userTwo))
ib := mb.ForIssue(117) // arbitrary issue id
masterE, err := mb.Get()
assert.NoError(t, err)
issueE, err := ib.Get()
assert.NoError(t, err)
assert.Equal(t, masterE, issueE)
// Add to the IssueExpectations
assert.NoError(t, ib.AddChange(ctx, types.Expectations{
data.AlphaTest: {
data.AlphaGood1Digest: types.POSITIVE, // overwrites previous
},
data.BetaTest: {
data.BetaGood1Digest: types.POSITIVE,
},
}, userOne))
// Add to the MasterExpectations
assert.NoError(t, mb.AddChange(ctx, types.Expectations{
data.AlphaTest: {
data.AlphaBad1Digest: types.NEGATIVE,
},
}, userOne))
masterE, err = mb.Get()
assert.NoError(t, err)
issueE, err = ib.Get()
assert.NoError(t, err)
// Make sure the IssueExpectations did not leak to the MasterExpectations
assert.Equal(t, types.Expectations{
data.AlphaTest: {
data.AlphaGood1Digest: types.NEGATIVE,
data.AlphaBad1Digest: types.NEGATIVE,
},
}, masterE)
// Make sure the IssueExpectations are applied on top of the updated
// MasterExpectations.
assert.Equal(t, types.Expectations{
data.AlphaTest: {
data.AlphaGood1Digest: types.POSITIVE,
data.AlphaBad1Digest: types.NEGATIVE,
},
data.BetaTest: {
data.BetaGood1Digest: types.POSITIVE,
},
}, issueE)
}
// TestIssueExpectationsQueryLog makes sure the QueryLogs interacts
// with the IssueExpectations as expected. Which is to say, the two
// logs are separate.
func TestIssueExpectationsQueryLog(t *testing.T) {
unittest.ManualTest(t)
unittest.RequiresFirestoreEmulator(t)
c := getTestFirestoreInstance(t)
mb := New(c, nil, ReadWrite)
ctx := context.Background()
assert.NoError(t, mb.AddChange(ctx, types.Expectations{
data.AlphaTest: {
data.AlphaGood1Digest: types.POSITIVE,
},
}, userTwo))
ib := mb.ForIssue(117) // arbitrary issue id
assert.NoError(t, ib.AddChange(ctx, types.Expectations{
data.BetaTest: {
data.BetaGood1Digest: types.POSITIVE,
},
}, userOne))
// Make sure the master logs are separate from the issue logs.
// request up to 10 to make sure we would get the issue
// change (if the filtering was wrong).
entries, n, err := mb.QueryLog(ctx, 0, 10, true)
assert.NoError(t, err)
assert.Equal(t, 1, n)
now := time.Now()
normalizeEntries(t, now, entries)
assert.Equal(t, expstorage.TriageLogEntry{
ID: "was_random_0",
Name: userTwo,
TS: now.Unix(),
ChangeCount: 1,
Details: []expstorage.TriageDetail{
{
TestName: data.AlphaTest,
Digest: data.AlphaGood1Digest,
Label: types.POSITIVE.String(),
},
},
}, entries[0])
// Make sure the issue logs are separate from the master logs.
// Unlike when getting the expectations, the issue logs are
// *only* those logs that affected this issue. Not, for example,
// all the master logs with the issue logs tacked on.
entries, n, err = ib.QueryLog(ctx, 0, 10, true)
assert.NoError(t, err)
assert.Equal(t, 1, n) // only one change on this branch
normalizeEntries(t, now, entries)
assert.Equal(t, expstorage.TriageLogEntry{
ID: "was_random_0",
Name: userOne,
TS: now.Unix(),
ChangeCount: 1,
Details: []expstorage.TriageDetail{
{
TestName: data.BetaTest,
Digest: data.BetaGood1Digest,
Label: types.POSITIVE.String(),
},
},
}, entries[0])
}
// fillWith4Entries fills a given Store with 4 triaged records of a few digests.
func fillWith4Entries(t *testing.T, f *Store) {
ctx := context.Background()
assert.NoError(t, f.AddChange(ctx, types.Expectations{
data.AlphaTest: {
data.AlphaGood1Digest: types.NEGATIVE,
},
}, userOne))
assert.NoError(t, f.AddChange(ctx, types.Expectations{
data.AlphaTest: {
data.AlphaGood1Digest: types.POSITIVE, // overwrites previous value
},
}, userTwo))
assert.NoError(t, f.AddChange(ctx, types.Expectations{
data.BetaTest: {
data.BetaGood1Digest: types.POSITIVE,
},
}, userOne))
assert.NoError(t, f.AddChange(ctx, types.Expectations{
data.AlphaTest: {
data.AlphaBad1Digest: types.NEGATIVE,
},
data.BetaTest: {
data.BetaUntriaged1Digest: types.UNTRIAGED,
},
}, userTwo))
}
// Some parts of the entries (timestamp and id) are non-deterministic
// Make sure they are valid, then replace them with deterministic values
// for an easier comparison.
func normalizeEntries(t *testing.T, now time.Time, entries []expstorage.TriageLogEntry) {
for i, te := range entries {
assert.NotEqual(t, "", te.ID)
te.ID = "was_random_" + strconv.Itoa(i)
ts := time.Unix(te.TS, 0)
assert.False(t, ts.IsZero())
assert.True(t, now.After(ts))
te.TS = now.Unix()
entries[i] = te
}
}
// Creates an empty firestore instance. The emulator keeps the tables in ram, but
// by appending a random nonce, we can be assured the collection we get is empty.
func getTestFirestoreInstance(t *testing.T) *firestore.Client {
randInstance := uuid.New().String()
c, err := firestore.NewClient(context.Background(), "should-use-emulator", "gold-test", ExpectationStoreCollection+randInstance, nil)
assert.NoError(t, err)
return c
}
// makeBigExpectations makes n tests named from start to end that each have 32 digests.
func makeBigExpectations(start, end int) types.Expectations {
e := types.Expectations{}
for i := start; i < end; i++ {
for j := 0; j < 32; j++ {
e.AddDigest(types.TestName(fmt.Sprintf("test-%03d", i)),
types.Digest(fmt.Sprintf("digest-%03d", j)), types.POSITIVE)
}
}
return e
}
const (
userOne = "userOne@example.com"
userTwo = "userTwo@example.com"
)