lemmy-reply-bot/main_test.go

341 lines
9.6 KiB
Go

package main
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"reflect"
"sync"
"testing"
"time"
"github.com/jarcoal/httpmock"
"go.elara.ws/go-lemmy"
"go.elara.ws/go-lemmy/types"
"go.elara.ws/lemmy-reply-bot/internal/store"
)
func TestCreate(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
// register list API endpoints
registerListComments(t)
registerListPosts(t)
var commentReplies []string
var postReplies []string
// Whenver the create comment endpoint is called, if the comment is replying to a post,
// append it to the postReplies slice. If it's replying to another comment, append it to
// the commentReplies slice.
httpmock.RegisterResponder("POST", "https://lemmy.example.com/api/v3/comment", func(r *http.Request) (*http.Response, error) {
var cc types.CreateComment
if err := json.NewDecoder(r.Body).Decode(&cc); err != nil {
t.Fatal("Error decoding CreateComment request:", err)
}
// Check whether the comment is replying to a post or another comment
if cc.PostID != 0 {
// If the comment is a reply to a post, append it to postReplies
postReplies = append(postReplies, cc.Content)
} else {
// If the comment is a reply to another comment, append it to commentReplies
commentReplies = append(commentReplies, cc.Content)
}
// Return a successful response
return httpmock.NewJsonResponse(200, types.CommentResponse{})
})
// Open a new in-memory reply database
db, err := openDB(":memory:")
if err != nil {
t.Fatal("Error opening in-memory database:", err)
}
defer db.Close()
// Create a context that will get canceled in 5 seconds
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Run the workers concurrently
wg := initWorkers(t, ctx, db)
// Wait for the workers to stop due to context cancellation
wg.Wait()
expectedCommentReplies := []string{"pong", "Lemmy Comment!"}
expectedPostReplies := []string{"pong", "Lemmy Post!"}
if !reflect.DeepEqual(commentReplies, expectedCommentReplies) {
t.Errorf("[Comment] Expected %v, got %v", expectedCommentReplies, commentReplies)
}
if !reflect.DeepEqual(postReplies, expectedPostReplies) {
t.Errorf("[Post] Expected %v, got %v", expectedPostReplies, postReplies)
}
}
func TestEdit(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
// register list API endpoints
registerListComments(t)
registerListPosts(t)
// We don't care about new comments in this test case, so we don't do anything in the comment handler
httpmock.RegisterResponder("POST", "https://lemmy.example.com/api/v3/comment", func(r *http.Request) (*http.Response, error) {
return httpmock.NewJsonResponse(200, types.CommentResponse{})
})
edited := map[int]string{}
// Whenever the edit comment endpoint is called, add the edited comment to
// the edited map, so that it can be checked later
httpmock.RegisterResponder("PUT", "https://lemmy.example.com/api/v3/comment", func(r *http.Request) (*http.Response, error) {
var ec types.EditComment
if err := json.NewDecoder(r.Body).Decode(&ec); err != nil {
t.Fatal("Error decoding CreateComment request:", err)
}
edited[ec.CommentID] = ec.Content.ValueOr("")
return httpmock.NewJsonResponse(200, types.CommentResponse{})
})
// Open a new in-memory reply database
db, err := openDB(":memory:")
if err != nil {
t.Fatal("Error opening in-memory database:", err)
}
defer db.Close()
rs := store.New(db)
// Add a new comment with id 12 that was updated before the fake one in
// registerListComments. This will cause the bot to edit that comment.
rs.AddItem(context.Background(), store.AddItemParams{
ID: 12,
ItemType: store.Comment,
UpdatedTime: 0,
})
// Set the reply ID of comment id 12 to 100 so we know it edited the
// right comment when it calls the edit API endpoint.
rs.SetReplyID(context.Background(), store.SetReplyIDParams{
ID: 12,
ReplyID: 100,
})
// Add a new post with id 3 that was updated before the fake one in
// registerListPosts. This will cause the bot to edit that comment.
rs.AddItem(context.Background(), store.AddItemParams{
ID: 3,
ItemType: store.Post,
UpdatedTime: 0,
})
// Set the reply ID of post id 3 to 100 so we know it edited the
// right comment when it calls the edit API endpoint.
rs.SetReplyID(context.Background(), store.SetReplyIDParams{
ID: 3,
ReplyID: 101,
})
// Create a context that will get canceled in 5 seconds
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Run the workers concurrently
wg := initWorkers(t, ctx, db)
// Wait for the workers to stop due to context cancellation
wg.Wait()
expected := map[int]string{
100: "Lemmy Comment!",
101: "Lemmy Post!",
}
if !reflect.DeepEqual(edited, expected) {
t.Errorf("Expected %v, got %v", expected, edited)
}
}
// testConfig returns a new Config for testing purposes.
func testConfig(t *testing.T) Config {
t.Helper()
cfgFile := &ConfigFile{
Replies: []Reply{
{
Regex: "ping",
Msg: "pong",
},
{
Regex: "Hello, (.+)",
Msg: "{{.Match 0 1}}!",
},
},
}
compiledRegexes, compiledTmpls, err := compileReplies(cfgFile.Replies)
if err != nil {
t.Fatal("Error compiling replies:", err)
}
return Config{
ConfigFile: cfgFile,
Regexes: compiledRegexes,
Tmpls: compiledTmpls,
PollInterval: time.Second,
}
}
// initWorkers does some setup and then starts the bot workers in separate goroutines.
// It returns a WaitGroup that's released when both of the workers return
func initWorkers(t *testing.T, ctx context.Context, db *sql.DB) *sync.WaitGroup {
t.Helper()
// Register a login endpoint that always returns test_token
httpmock.RegisterResponder("POST", "https://lemmy.example.com/api/v3/user/login", func(r *http.Request) (*http.Response, error) {
return httpmock.NewJsonResponse(200, types.LoginResponse{JWT: types.NewOptional("test_token")})
})
// Create a new lemmy client using the mocked instance
c, err := lemmy.New("https://lemmy.example.com")
if err != nil {
t.Fatal("Error creating lemmy client:", err)
}
// Log in to the fake instance
err = c.ClientLogin(ctx, types.Login{
UsernameOrEmail: "test_username",
Password: "test_password",
})
if err != nil {
t.Fatal("Error logging in to mocked client:", err)
}
// Create a config for testing
cfg := testConfig(t)
rs := store.New(db)
replyCh := make(chan replyJob, 200)
wg := &sync.WaitGroup{}
wg.Add(2)
go func() {
defer wg.Done()
commentWorker(ctx, c, cfg, rs, replyCh)
}()
go func() {
defer wg.Done()
commentReplyWorker(ctx, c, rs, replyCh)
}()
return wg
}
// registerListComments registers an HTTP mock for the /comment/list API endpoint
func registerListComments(t *testing.T) {
t.Helper()
httpmock.RegisterResponder("GET", `=~^https://lemmy\.example\.com/api/v3/comment/list\?.*`, func(r *http.Request) (*http.Response, error) {
return httpmock.NewJsonResponse(200, types.GetCommentsResponse{
Comments: []types.CommentView{
{
Comment: types.Comment{ // Should match reply index 0
ID: 10,
Published: types.LemmyTime{Time: time.Unix(1550164620, 0)},
Content: "ping",
},
Community: types.Community{
Local: true,
},
},
{
Comment: types.Comment{ // Should be skipped due to non-local community
ID: 11,
Published: types.LemmyTime{Time: time.Unix(1550164620, 0)},
Content: "ping",
},
Community: types.Community{
Local: false,
},
},
{
Comment: types.Comment{ // Should match reply index 1
ID: 12,
Published: types.LemmyTime{Time: time.Unix(1550164620, 0)},
Updated: types.LemmyTime{Time: time.Unix(1581700620, 0)},
Content: "Hello, Lemmy Comment",
},
Community: types.Community{
Local: true,
},
},
{
Comment: types.Comment{ // Shouldn't match
ID: 13,
Published: types.LemmyTime{Time: time.Unix(1550164620, 0)},
Content: "This comment doesn't match any replies",
},
Community: types.Community{
Local: true,
},
},
},
})
})
}
// registerListPosts registers an HTTP mock for the /post/list API endpoint
func registerListPosts(t *testing.T) {
t.Helper()
httpmock.RegisterResponder("GET", `=~^https://lemmy\.example\.com/api/v3/post/list\?.*`, func(r *http.Request) (*http.Response, error) {
return httpmock.NewJsonResponse(200, types.GetPostsResponse{
Posts: []types.PostView{
{
Post: types.Post{ // Should match reply index 0
ID: 1,
Published: types.LemmyTime{Time: time.Unix(1550164620, 0)},
Body: types.NewOptional("ping"),
},
Community: types.Community{
Local: true,
},
},
{
Post: types.Post{ // Should be skipped due to non-local community
ID: 2,
Published: types.LemmyTime{Time: time.Unix(1550164620, 0)},
Body: types.NewOptional("ping"),
},
Community: types.Community{
Local: false,
},
},
{
Post: types.Post{ // Should match reply index 1
ID: 3,
Published: types.LemmyTime{Time: time.Unix(1550164620, 0)},
Updated: types.LemmyTime{Time: time.Unix(1581700620, 0)},
Body: types.NewOptional("Hello, Lemmy Post"),
},
Community: types.Community{
Local: true,
},
},
{
Post: types.Post{ // Shouldn't match
ID: 4,
Published: types.LemmyTime{Time: time.Unix(1550164620, 0)},
Body: types.NewOptional("This comment doesn't match any replies"),
},
Community: types.Community{
Local: true,
},
},
},
})
})
}