| // Copyright 2024 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| package gerrit |
| |
| import ( |
| "context" |
| "encoding/json" |
| "errors" |
| "fmt" |
| "iter" |
| "os" |
| "reflect" |
| "strconv" |
| "strings" |
| "testing" |
| "time" |
| |
| "golang.org/x/oscar/internal/storage" |
| "golang.org/x/tools/txtar" |
| ) |
| |
| // divertChanges reports whether changes and their |
| // comments are being diverted for testing purposes. |
| func (c *Client) divertChanges() bool { |
| return c.testing && c.testClient != nil |
| } |
| |
| // Testing returns a TestingClient, which provides access to Client functionality |
| // intended for testing. |
| // Testing only returns a non-nil TestingClient in testing mode, |
| // which is active if the current program is a test binary (that is, [testing.Testing] returns true). |
| // Otherwise, Testing returns nil. |
| // |
| // Each Client has only one TestingClient associated with it. Every call to Testing returns the same TestingClient. |
| func (c *Client) Testing() *TestingClient { |
| if !testing.Testing() && !c.testing { |
| return nil |
| } |
| |
| c.testMu.Lock() |
| defer c.testMu.Unlock() |
| if c.testClient == nil { |
| c.testClient = newTestingClient(c) |
| } |
| return c.testClient |
| } |
| |
| // A TestingClient provides access to [Client] functionality intended for testing. |
| type TestingClient struct { |
| c *Client |
| chs []*ChangeInfo // change updates, in reverse chronological order |
| queryLimit int // mimic Gerrit query limits |
| comments map[int][]*CommentInfo // comments indexed by change number |
| } |
| |
| func newTestingClient(c *Client) *TestingClient { |
| return &TestingClient{c: c, comments: make(map[int][]*CommentInfo)} |
| } |
| |
| func (tc *TestingClient) limit() int { |
| tc.c.testMu.Lock() |
| defer tc.c.testMu.Unlock() |
| return tc.queryLimit |
| } |
| |
| func (tc *TestingClient) setLimit(l int) { |
| tc.c.testMu.Lock() |
| defer tc.c.testMu.Unlock() |
| tc.queryLimit = l |
| } |
| |
| // LoadTxtar loads a change info history from the named txtar file, |
| // and adds it to tc.chs. |
| // |
| // The file should contain a txtar archive (see [golang.org/x/tools/txtar]). |
| // Each file in the archive may be named “change#n” (for example “change#1”). |
| // A line in the file must be in the format "key: value", where "key" is one |
| // of the fields of [ChangeInfo] type. |
| func (tc *TestingClient) LoadTxtar(file string) error { |
| data, err := os.ReadFile(file) |
| if err != nil { |
| return err |
| } |
| err = tc.LoadTxtarData(data) |
| if err != nil { |
| err = &os.PathError{Op: "load", Path: file, Err: err} |
| } |
| return err |
| } |
| |
| // timeStampType is the [reflect.Type] of [TimeStamp]. |
| var timeStampType = reflect.TypeFor[TimeStamp]() |
| |
| // accountInfoType is the [reflect.Type] of [*AccountInfo]. |
| var accountInfoType = reflect.TypeFor[*AccountInfo]() |
| |
| // LoadTxtarData loads a change info history from the txtar file content data. |
| // See [LoadTxtar] for a description of the format. |
| func (tc *TestingClient) LoadTxtarData(data []byte) error { |
| ar := txtar.Parse(data) |
| for _, file := range ar.Files { |
| data := string(file.Data) |
| // Skip the name and proceed to read headers. |
| c := &ChangeInfo{} |
| cv := reflect.ValueOf(c).Elem() |
| if _, err := tc.setFields(file.Name, data, 0, cv); err != nil { |
| return err |
| } |
| tc.c.testMu.Lock() |
| tc.chs = append(tc.chs, c) |
| tc.c.testMu.Unlock() |
| } |
| return nil |
| } |
| |
| // setFields reads field values and sets fields in a struct accordingly. |
| // The indent parameter says how many spaces are required on each line; |
| // this supports fields that are themselves structs. |
| // This returns the remaining data. |
| func (tc *TestingClient) setFields(filename, data string, indent int, st reflect.Value) (string, error) { |
| prefix := strings.Repeat(" ", indent) |
| for { |
| line, rest, _ := strings.Cut(data, "\n") |
| if line == "" { |
| data = rest |
| break |
| } |
| line, ok := strings.CutPrefix(line, prefix) |
| if !ok { |
| // Don't change data. |
| break |
| } |
| data = rest |
| rest, err := tc.setField(filename, line, data, indent, st) |
| if err != nil { |
| return "", err |
| } |
| data = rest |
| } |
| return data, nil |
| } |
| |
| // setField takes a struct and a line that sets a scalar field. |
| // The line should have the form "key: value", |
| // where "key" is the name of a field in the struct and |
| // "value is the value we want to set it to. |
| // The one exception are lines whose "key" is "Comment". The value |
| // for such lines must be [CommentInfo]. If such comment lines exist, |
| // they need to be preceeded by a "Number: value" line and st must be |
| // of type [ChangeInfo]. Comments are added to tc.comments. |
| // This isn't general, it only handles the cases that arise |
| // for Gerrit types. |
| // The data argument is the data following the line, |
| // used for multi-line values. |
| // This returns the remaining data. |
| func (tc *TestingClient) setField(filename string, line, data string, indent int, st reflect.Value) (string, error) { |
| key, val, ok := strings.Cut(line, ":") |
| if !ok { |
| return "", fmt.Errorf("%s: invalid line: %q", filename, line) |
| } |
| val = strings.TrimSpace(val) |
| |
| field := st.FieldByName(key) |
| if !field.IsValid() { |
| if ch, ok := st.Interface().(ChangeInfo); ok && key == "Comment" { // parse comments |
| if ch.Number == 0 { |
| return "", errors.New("change Number not set before Comment lines") |
| } |
| var cm CommentInfo |
| cmv := reflect.ValueOf(&cm).Elem() |
| data, err := tc.setFields(filename, data, indent+1, cmv) |
| if err != nil { |
| return "", err |
| } |
| tc.c.testMu.Lock() |
| tc.comments[ch.Number] = append(tc.comments[ch.Number], &cm) |
| tc.c.testMu.Unlock() |
| return data, nil |
| } |
| return "", fmt.Errorf("%s: unrecognized field name %q in %s", filename, key, st.Type()) |
| } |
| |
| var vval reflect.Value |
| switch field.Type().Kind() { |
| case reflect.Bool: |
| b, err := strconv.ParseBool(val) |
| if err != nil { |
| return "", fmt.Errorf("%s: field %q: can't parse %q as bool", filename, key, val) |
| } |
| vval = reflect.ValueOf(b) |
| |
| case reflect.Int: |
| i, err := strconv.Atoi(val) |
| if err != nil { |
| return "", fmt.Errorf("%s: field %q: can't parse %q as int", filename, key, val) |
| } |
| vval = reflect.ValueOf(i) |
| |
| case reflect.String: |
| vval = reflect.ValueOf(val) |
| |
| case reflect.Struct: |
| if field.Type() == timeStampType { |
| t, err := timestamp(val) |
| if err != nil { |
| return "", fmt.Errorf("%s: field %q: can't parse %q as timestamp", filename, key, val) |
| } |
| vval = reflect.ValueOf(TimeStamp(t)) |
| break |
| } |
| return "", fmt.Errorf("%s: field %q: unexpected struct type %s", filename, key, field.Type()) |
| |
| case reflect.Pointer: |
| if field.Type() == accountInfoType { |
| // For an account we just an email address. |
| if val == "" { |
| return "", fmt.Errorf("%s: field %q: missing email address for AccountInfo", filename, key) |
| } |
| vval = reflect.ValueOf(&AccountInfo{ |
| AccountID: makeTestAccountID(val), |
| Email: val, |
| }) |
| break |
| } |
| if field.Type().Elem().Kind() != reflect.Struct { |
| return "", fmt.Errorf("%s: field %q: unexpected pointer to %s", filename, key, field.Type().Elem()) |
| } |
| |
| // For struct types in general we expect the fields |
| // to follow, indented. |
| vval = reflect.New(field.Type().Elem()) |
| rest, err := tc.setFields(filename, data, indent+1, vval.Elem()) |
| if err != nil { |
| return "", err |
| } |
| data = rest |
| break |
| |
| case reflect.Slice: |
| switch field.Type().Elem().Kind() { |
| case reflect.Int: |
| // For ints just put all the values on one line. |
| var ints []int |
| for _, vi := range strings.Fields(val) { |
| i, err := strconv.Atoi(vi) |
| if err != nil { |
| return "", fmt.Errorf("%s: field %q: %v", filename, key, err) |
| } |
| ints = append(ints, i) |
| } |
| vval = reflect.ValueOf(ints) |
| |
| case reflect.String: |
| // For strings just put all the values on one line. |
| // Strings are space separated, no quoting. |
| var strs []string |
| for _, vs := range strings.Fields(val) { |
| vs = strings.TrimSpace(vs) |
| if vs == "" { |
| continue |
| } |
| strs = append(strs, vs) |
| } |
| vval = reflect.ValueOf(strs) |
| |
| case reflect.Pointer: |
| if field.Type().Elem().Elem().Kind() != reflect.Struct { |
| return "", fmt.Errorf("%s: field %q: pointer not to struct in type %s", filename, key, field.Type()) |
| } |
| vval = reflect.New(field.Type().Elem().Elem()) |
| rest, err := tc.setFields(filename, data, indent+1, vval.Elem()) |
| if err != nil { |
| return "", err |
| } |
| data = rest |
| vval = reflect.Append(field, vval) |
| |
| default: |
| return "", fmt.Errorf("%s: field %q: unsupported slice type %s", filename, key, field.Type()) |
| } |
| |
| case reflect.Map: |
| if field.Type().Key().Kind() != reflect.String { |
| return "", fmt.Errorf("%s: field %q: unsupported map key type in %s", filename, key, field.Type()) |
| } |
| |
| if field.IsZero() { |
| field.Set(reflect.MakeMap(field.Type())) |
| } |
| |
| switch field.Type().Elem().Kind() { |
| case reflect.String: |
| mkey, mval, ok := strings.Cut(val, ":") |
| if !ok { |
| return "", fmt.Errorf("%s: field %q: expected key: val in map to string", filename, key) |
| } |
| mkey = strings.TrimSpace(mkey) |
| mval = strings.TrimSpace(mval) |
| field.SetMapIndex(reflect.ValueOf(mkey), reflect.ValueOf(mval)) |
| // Don't fall through to bottom of function. |
| return data, nil |
| |
| case reflect.Pointer: |
| if field.Type().Elem().Elem().Kind() != reflect.Struct { |
| return "", fmt.Errorf("%s: field %q: pointer not to struct in map element type in %s", filename, key, field.Type()) |
| } |
| vval = reflect.New(field.Type().Elem().Elem()) |
| rest, err := tc.setFields(filename, data, indent+1, vval.Elem()) |
| if err != nil { |
| return "", err |
| } |
| data = rest |
| field.SetMapIndex(reflect.ValueOf(val), vval) |
| // Don't fall through to bottom of function. |
| return data, nil |
| |
| case reflect.Slice: |
| typ := field.Type().Elem() |
| if typ.Elem().Kind() != reflect.Pointer || typ.Elem().Elem().Kind() != reflect.Struct { |
| return "", fmt.Errorf("%s: field %q: unsupported map slice element type in %s", filename, key, field.Type()) |
| } |
| vval = reflect.New(typ.Elem().Elem()) |
| rest, err := tc.setFields(filename, data, indent+1, vval.Elem()) |
| if err != nil { |
| return "", err |
| } |
| data = rest |
| key := reflect.ValueOf(val) |
| old := field.MapIndex(key) |
| if !old.IsValid() { |
| old = reflect.MakeSlice(typ, 0, 1) |
| } |
| vval = reflect.Append(old, vval) |
| field.SetMapIndex(key, vval) |
| // Don't fall through to bottom of function. |
| return data, nil |
| |
| default: |
| return "", fmt.Errorf("%s: field %q: unsupported map element type in %s", filename, key, field.Type()) |
| } |
| |
| default: |
| return "", fmt.Errorf("%s: field %q: unsupported type %s", filename, key, field.Type()) |
| } |
| |
| if key == "interrupt" { |
| // Special case for the only unexported field. |
| st.Addr().Interface().(*ChangeInfo).interrupt = vval.Bool() |
| } else { |
| field.Set(vval) |
| } |
| |
| return data, nil |
| } |
| |
| // changes returns an iterator of change updates in tc.chs that are updated |
| // in the interval [after, before], in reverse chronological order. First |
| // skip number of matching change updates are disregarded. |
| func (tc *TestingClient) changes(_ context.Context, project string, after, before string, skip int) iter.Seq2[json.RawMessage, error] { |
| return func(yield func(json.RawMessage, error) bool) { |
| skipped := 0 |
| inInterval := false |
| yielded := 0 // yielded in a single batch |
| for _, c := range tc.chs { |
| in, err := updatedIn(c, after, before) |
| if err != nil { |
| yield(nil, err) |
| return |
| } |
| if !in { |
| if inInterval { // reached outside of the interval |
| return |
| } |
| continue |
| } |
| |
| // We are inside the matching interval. |
| inInterval = true |
| if skip > 0 && skipped < skip { |
| skipped++ |
| continue |
| } |
| |
| cj, err := json.Marshal(c) |
| if err != nil { |
| yield(nil, err) |
| return |
| } |
| |
| if c.Project != project { |
| continue |
| } |
| |
| yielded++ |
| if !yield(cj, nil) { |
| return |
| } |
| |
| if c.interrupt { // fake an interruption |
| yield(nil, errors.New("test interrupt error")) |
| return |
| } |
| |
| if yielded >= tc.limit() { // reached the batch limit |
| return |
| } |
| } |
| } |
| } |
| |
| // updatedIn reports if c was updated in the [after, before] interval. |
| // Both after and before must be in gerrit timestamp layout. |
| func updatedIn(c *ChangeInfo, after, before string) (bool, error) { |
| u := c.Updated.Time() |
| |
| ain := true |
| if after != "" { |
| a, err := timestamp(after) |
| if err != nil { |
| return false, err |
| } |
| ain = a.Time().Equal(u) || a.Time().Before(u) |
| } |
| bin := true |
| if before != "" { |
| b, err := timestamp(before) |
| if err != nil { |
| return false, err |
| } |
| bin = b.Time().Equal(u) || b.Time().After(u) |
| } |
| return ain && bin, nil |
| } |
| |
| // changeNumbers returns the data for the testing changes. |
| func (tc *TestingClient) changeNumbers() iter.Seq2[int, func() *Change] { |
| return func(yield func(int, func() *Change) bool) { |
| for _, ch := range tc.chs { |
| cfn := func() *Change { |
| return &Change{ |
| num: ch.Number, |
| data: storage.JSON(ch), |
| } |
| } |
| if !yield(ch.Number, cfn) { |
| return |
| } |
| } |
| } |
| } |
| |
| // change returns the data for a single testing change. |
| func (tc *TestingClient) change(changeNum int) *Change { |
| for _, ch := range tc.chs { |
| if ch.Number == changeNum { |
| return &Change{ |
| num: changeNum, |
| data: storage.JSON(ch), |
| } |
| } |
| } |
| return nil |
| } |
| |
| func timestamp(gt string) (TimeStamp, error) { |
| var ts TimeStamp |
| if err := ts.UnmarshalJSON([]byte(quote(gt))); err != nil { |
| return TimeStamp(time.Time{}), err |
| } |
| return ts, nil |
| } |
| |
| var ( |
| testAccounts = make(map[string]int) |
| testAccountID int |
| ) |
| |
| // makeTestAccountID maintains a mapping from account email to account ID. |
| // This lets most testing accounts just provide an email. |
| func makeTestAccountID(email string) int { |
| if id, ok := testAccounts[email]; ok { |
| return id |
| } |
| testAccountID++ |
| testAccounts[email] = testAccountID |
| return testAccountID |
| } |