| // 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 googlegroups |
| |
| import ( |
| "context" |
| "errors" |
| "fmt" |
| "iter" |
| "os" |
| "strconv" |
| "strings" |
| "testing" |
| "time" |
| |
| "golang.org/x/tools/txtar" |
| ) |
| |
| // divertConversations reports whether conversations |
| // 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 |
| } |
| |
| func newTestingClient(c *Client) *TestingClient { |
| return &TestingClient{c: c, interrupted: make(map[string]bool)} |
| } |
| |
| // A TestingClient provides access to [Client] functionality intended for testing. |
| type TestingClient struct { |
| c *Client |
| convs []*Conversation // conversation updates, in reverse chronological order |
| searchLimit int // mimic Google Groups search limits |
| // interrupted keeps track for which conversations |
| // we already injected interruption. This is needed |
| // since Google Groups time stamps are not fine grained |
| // enough for the next invocation of [Client.Sync] just |
| // to continue from the interrupted conversation. |
| interrupted map[string]bool |
| } |
| |
| func (tc *TestingClient) limit() int { |
| tc.c.testMu.Lock() |
| defer tc.c.testMu.Unlock() |
| return tc.searchLimit |
| } |
| |
| func (tc *TestingClient) setLimit(l int) { |
| tc.c.testMu.Lock() |
| defer tc.c.testMu.Unlock() |
| tc.searchLimit = l |
| } |
| |
| // LoadTxtar loads a conversation info history from the named txtar file, |
| // and adds it to tc.convs. |
| // |
| // The file should contain a txtar archive (see [golang.org/x/tools/txtar]). |
| // Each file in the archive may be named “conversation #n” (for example |
| // “conversation#1”). |
| // A line in the file must be in the format "key: value", where "key" is one |
| // of the fields of [Conversation] 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 |
| } |
| |
| // 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 := &Conversation{} |
| for { |
| line, rest, _ := strings.Cut(data, "\n") |
| data = rest |
| if line == "" { |
| break |
| } |
| key, val, ok := strings.Cut(line, ":") |
| if !ok { |
| return fmt.Errorf("%s: invalid header line: %q", file.Name, line) |
| } |
| val = strings.TrimSpace(val) |
| if val == "" { |
| continue |
| } |
| switch key { |
| case "Group": |
| c.Group = val |
| case "Title": |
| c.Title = val |
| case "URL": |
| c.URL = val |
| case "HTML": |
| c.Messages = []string{val} |
| case "Updated": |
| c.updated = val |
| case "interrupt": |
| b, err := strconv.ParseBool(val) |
| if err != nil { |
| return err |
| } |
| c.interrupt = b |
| } |
| } |
| tc.c.testMu.Lock() |
| tc.convs = append(tc.convs, c) |
| tc.c.testMu.Unlock() |
| } |
| return nil |
| } |
| |
| func (tc *TestingClient) conversations(_ context.Context, group, after, before string) iter.Seq2[*Conversation, error] { |
| return func(yield func(*Conversation, error) bool) { |
| inInterval := false |
| yielded := 0 // yielded in a single batch |
| for _, c := range tc.convs { |
| 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 c.Group != group { |
| continue |
| } |
| |
| yielded++ |
| if !yield(c, nil) { |
| return |
| } |
| |
| // Fake an interruption if the same interruption |
| // was not injected earlier. |
| if c.interrupt && !tc.interrupted[c.URL] { |
| tc.interrupted[c.URL] = true |
| yield(nil, errors.New("test interrupt error")) |
| return |
| } |
| |
| if yielded >= tc.limit() { // reached the search limit |
| return |
| } |
| } |
| } |
| } |
| |
| // updatedIn reports if c was updated in the (after, before) interval. |
| // Both after and before must be in timeStampLayout. |
| func updatedIn(c *Conversation, after, before string) (bool, error) { |
| u, err := time.Parse(timeStampLayout, c.updated) |
| if err != nil { |
| return false, err |
| } |
| |
| ain := true |
| if after != "" { |
| a, err := time.Parse(timeStampLayout, after) |
| if err != nil { |
| return false, err |
| } |
| ain = a.Before(u) |
| } |
| bin := true |
| if before != "" { |
| b, err := time.Parse(timeStampLayout, before) |
| if err != nil { |
| return false, err |
| } |
| bin = b.After(u) |
| } |
| return ain && bin, nil |
| } |