blob: a85fb8b5057ab0dd4680cb52a3a62434e999b78b [file] [log] [blame]
// 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
}