check / check (push) Waiting to run
Test databases in internal/handlers and internal/service were named after the address of the running test. A later test could get the address of a finished one, and with it a database that test's server might still hold open. Each test database now gets a random name, as in internal/ircserver. A new test in each package opens two test databases at once and checks that they do not share one; both new tests fail with the old names. Model: opus-5-5 Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
633 lines
13 KiB
Go
633 lines
13 KiB
Go
// Tests use a global viper instance for configuration,
|
|
// making parallel execution unsafe.
|
|
//
|
|
//nolint:paralleltest
|
|
package service_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.uber.org/fx"
|
|
"go.uber.org/fx/fxtest"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"sneak.berlin/go/neoirc/internal/broker"
|
|
"sneak.berlin/go/neoirc/internal/config"
|
|
"sneak.berlin/go/neoirc/internal/db"
|
|
"sneak.berlin/go/neoirc/internal/globals"
|
|
"sneak.berlin/go/neoirc/internal/logger"
|
|
"sneak.berlin/go/neoirc/internal/service"
|
|
"sneak.berlin/go/neoirc/pkg/irc"
|
|
)
|
|
|
|
func TestMain(m *testing.M) {
|
|
db.SetBcryptCost(bcrypt.MinCost)
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
// testEnv holds all dependencies for a service test.
|
|
type testEnv struct {
|
|
svc *service.Service
|
|
db *db.Database
|
|
broker *broker.Broker
|
|
app *fxtest.App
|
|
}
|
|
|
|
func newTestEnv(t *testing.T) *testEnv {
|
|
t.Helper()
|
|
|
|
// A random name, so that no other test can share it, not
|
|
// even one that has finished but still holds its database
|
|
// open.
|
|
dbURL := fmt.Sprintf(
|
|
"file:%s?mode=memory&cache=shared",
|
|
rand.Text(),
|
|
)
|
|
|
|
var (
|
|
database *db.Database
|
|
svc *service.Service
|
|
)
|
|
|
|
brk := broker.New()
|
|
|
|
app := fxtest.New(t,
|
|
fx.Provide(
|
|
func() *globals.Globals {
|
|
return &globals.Globals{
|
|
Appname: "neoirc-test",
|
|
Version: "test",
|
|
StartTime: time.Now(),
|
|
}
|
|
},
|
|
logger.New,
|
|
func(
|
|
lifecycle fx.Lifecycle,
|
|
globs *globals.Globals,
|
|
log *logger.Logger,
|
|
) (*config.Config, error) {
|
|
cfg, err := config.New(
|
|
lifecycle, config.Params{ //nolint:exhaustruct
|
|
Globals: globs, Logger: log,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"test config: %w", err,
|
|
)
|
|
}
|
|
|
|
cfg.DBURL = dbURL
|
|
cfg.Port = 0
|
|
cfg.OperName = "admin"
|
|
cfg.OperPassword = "secret"
|
|
|
|
return cfg, nil
|
|
},
|
|
func(
|
|
lifecycle fx.Lifecycle,
|
|
log *logger.Logger,
|
|
cfg *config.Config,
|
|
) (*db.Database, error) {
|
|
return db.New(lifecycle, db.Params{ //nolint:exhaustruct
|
|
Logger: log, Config: cfg,
|
|
})
|
|
},
|
|
func() *broker.Broker { return brk },
|
|
service.New,
|
|
),
|
|
fx.Populate(&database, &svc),
|
|
)
|
|
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(func() {
|
|
app.RequireStop()
|
|
})
|
|
|
|
return &testEnv{
|
|
svc: svc,
|
|
db: database,
|
|
broker: brk,
|
|
app: app,
|
|
}
|
|
}
|
|
|
|
// createSession is a test helper that creates a session
|
|
// and returns the session ID.
|
|
func createSession(
|
|
ctx context.Context,
|
|
t *testing.T,
|
|
database *db.Database,
|
|
nick string,
|
|
) int64 {
|
|
t.Helper()
|
|
|
|
sessionID, _, _, err := database.CreateSession(
|
|
ctx, nick, nick, "localhost", "127.0.0.1",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("create session %s: %v", nick, err)
|
|
}
|
|
|
|
return sessionID
|
|
}
|
|
|
|
// TestEachTestEnvHasItsOwnDatabase checks that a test
|
|
// environment starts on an empty database while another one
|
|
// is still open.
|
|
func TestEachTestEnvHasItsOwnDatabase(t *testing.T) {
|
|
ctx := t.Context()
|
|
|
|
first := newTestEnv(t)
|
|
createSession(ctx, t, first.db, "samenick")
|
|
|
|
second := newTestEnv(t)
|
|
createSession(ctx, t, second.db, "samenick")
|
|
}
|
|
|
|
func TestFanOut(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
sid1 := createSession(ctx, t, env.db, "alice")
|
|
sid2 := createSession(ctx, t, env.db, "bob")
|
|
|
|
body, _ := json.Marshal([]string{"hello"}) //nolint:errchkjson
|
|
|
|
dbID, uuid, err := env.svc.FanOut(
|
|
ctx, irc.CmdPrivmsg, "alice", "#test",
|
|
nil, body, nil,
|
|
[]int64{sid1, sid2},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("FanOut: %v", err)
|
|
}
|
|
|
|
if dbID == 0 {
|
|
t.Error("expected non-zero dbID")
|
|
}
|
|
|
|
if uuid == "" {
|
|
t.Error("expected non-empty UUID")
|
|
}
|
|
}
|
|
|
|
func TestJoinChannel(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
sid := createSession(ctx, t, env.db, "alice")
|
|
|
|
result, err := env.svc.JoinChannel(
|
|
ctx, sid, "alice", "#general", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("JoinChannel: %v", err)
|
|
}
|
|
|
|
if result.ChannelID == 0 {
|
|
t.Error("expected non-zero channel ID")
|
|
}
|
|
|
|
if !result.IsCreator {
|
|
t.Error("first joiner should be creator")
|
|
}
|
|
|
|
// Second user joins — not creator.
|
|
sid2 := createSession(ctx, t, env.db, "bob")
|
|
|
|
result2, err := env.svc.JoinChannel(
|
|
ctx, sid2, "bob", "#general", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("JoinChannel bob: %v", err)
|
|
}
|
|
|
|
if result2.IsCreator {
|
|
t.Error("second joiner should not be creator")
|
|
}
|
|
|
|
if result2.ChannelID != result.ChannelID {
|
|
t.Error("both should join the same channel")
|
|
}
|
|
}
|
|
|
|
func TestPartChannel(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
sid := createSession(ctx, t, env.db, "alice")
|
|
|
|
_, err := env.svc.JoinChannel(
|
|
ctx, sid, "alice", "#general", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("JoinChannel: %v", err)
|
|
}
|
|
|
|
err = env.svc.PartChannel(
|
|
ctx, sid, "alice", "#general", "bye",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("PartChannel: %v", err)
|
|
}
|
|
|
|
// Parting a non-existent channel returns error.
|
|
err = env.svc.PartChannel(
|
|
ctx, sid, "alice", "#nonexistent", "",
|
|
)
|
|
if err == nil {
|
|
t.Error("expected error for non-existent channel")
|
|
}
|
|
|
|
var ircErr *service.IRCError
|
|
if !errors.As(err, &ircErr) {
|
|
t.Errorf("expected IRCError, got %T", err)
|
|
}
|
|
}
|
|
|
|
func TestSendChannelMessage(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
sid1 := createSession(ctx, t, env.db, "alice")
|
|
sid2 := createSession(ctx, t, env.db, "bob")
|
|
|
|
_, err := env.svc.JoinChannel(
|
|
ctx, sid1, "alice", "#chat", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("join alice: %v", err)
|
|
}
|
|
|
|
_, err = env.svc.JoinChannel(
|
|
ctx, sid2, "bob", "#chat", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("join bob: %v", err)
|
|
}
|
|
|
|
body, _ := json.Marshal([]string{"hello world"}) //nolint:errchkjson
|
|
|
|
dbID, uuid, err := env.svc.SendChannelMessage(
|
|
ctx, sid1, "alice",
|
|
irc.CmdPrivmsg, "#chat", body, nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("SendChannelMessage: %v", err)
|
|
}
|
|
|
|
if dbID == 0 {
|
|
t.Error("expected non-zero dbID")
|
|
}
|
|
|
|
if uuid == "" {
|
|
t.Error("expected non-empty UUID")
|
|
}
|
|
|
|
// Non-member cannot send.
|
|
sid3 := createSession(ctx, t, env.db, "charlie")
|
|
|
|
_, _, err = env.svc.SendChannelMessage(
|
|
ctx, sid3, "charlie",
|
|
irc.CmdPrivmsg, "#chat", body, nil,
|
|
)
|
|
if err == nil {
|
|
t.Error("expected error for non-member send")
|
|
}
|
|
}
|
|
|
|
func TestBroadcastQuit(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
sid1 := createSession(ctx, t, env.db, "alice")
|
|
sid2 := createSession(ctx, t, env.db, "bob")
|
|
|
|
_, err := env.svc.JoinChannel(
|
|
ctx, sid1, "alice", "#room", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("join alice: %v", err)
|
|
}
|
|
|
|
_, err = env.svc.JoinChannel(
|
|
ctx, sid2, "bob", "#room", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("join bob: %v", err)
|
|
}
|
|
|
|
// BroadcastQuit should not panic and should clean up.
|
|
env.svc.BroadcastQuit(
|
|
ctx, sid1, "alice", "Goodbye",
|
|
)
|
|
|
|
// Session should be deleted.
|
|
_, lookupErr := env.db.GetSessionByNick(ctx, "alice")
|
|
if lookupErr == nil {
|
|
t.Error("expected session to be deleted after quit")
|
|
}
|
|
}
|
|
|
|
func TestSendChannelMessage_Moderated(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
sid1 := createSession(ctx, t, env.db, "alice")
|
|
sid2 := createSession(ctx, t, env.db, "bob")
|
|
|
|
result, err := env.svc.JoinChannel(
|
|
ctx, sid1, "alice", "#modchat", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("join alice: %v", err)
|
|
}
|
|
|
|
_, err = env.svc.JoinChannel(
|
|
ctx, sid2, "bob", "#modchat", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("join bob: %v", err)
|
|
}
|
|
|
|
// Set channel to moderated.
|
|
chID := result.ChannelID
|
|
_ = env.svc.SetChannelFlag(ctx, chID, 'm', true)
|
|
|
|
body, _ := json.Marshal([]string{"test"}) //nolint:errchkjson
|
|
|
|
// Bob (non-op, non-voiced) should fail to send.
|
|
_, _, err = env.svc.SendChannelMessage(
|
|
ctx, sid2, "bob",
|
|
irc.CmdPrivmsg, "#modchat", body, nil,
|
|
)
|
|
if err == nil {
|
|
t.Error("expected error for non-voiced user in moderated channel")
|
|
}
|
|
|
|
// Alice (operator) should succeed.
|
|
_, _, err = env.svc.SendChannelMessage(
|
|
ctx, sid1, "alice",
|
|
irc.CmdPrivmsg, "#modchat", body, nil,
|
|
)
|
|
if err != nil {
|
|
t.Errorf("operator should be able to send in moderated channel: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestQueryUserMode(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
sid := createSession(ctx, t, env.db, "alice")
|
|
|
|
modes, err := env.svc.QueryUserMode(ctx, sid)
|
|
if err != nil {
|
|
t.Fatalf("query user mode: %v", err)
|
|
}
|
|
|
|
if modes != "+" {
|
|
t.Errorf("expected +, got %s", modes)
|
|
}
|
|
|
|
setUserModes(ctx, t, env.db, sid, userModes{wallops: true})
|
|
|
|
modes, err = env.svc.QueryUserMode(ctx, sid)
|
|
if err != nil {
|
|
t.Fatalf("query user mode: %v", err)
|
|
}
|
|
|
|
if modes != "+w" {
|
|
t.Errorf("expected +w, got %s", modes)
|
|
}
|
|
|
|
setUserModes(
|
|
ctx, t, env.db, sid, userModes{oper: true, wallops: true},
|
|
)
|
|
|
|
modes, err = env.svc.QueryUserMode(ctx, sid)
|
|
if err != nil {
|
|
t.Fatalf("query user mode: %v", err)
|
|
}
|
|
|
|
if modes != "+ow" {
|
|
t.Errorf("expected +ow, got %s", modes)
|
|
}
|
|
|
|
// A flag that cannot be read is an error, not a flag
|
|
// that is unset.
|
|
cancelled, cancel := context.WithCancel(ctx)
|
|
cancel()
|
|
|
|
_, err = env.svc.QueryUserMode(cancelled, sid)
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Errorf("expected the read's error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUserhostReply(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
createSession(ctx, t, env.db, "alice")
|
|
bobID := createSession(ctx, t, env.db, "bob")
|
|
|
|
// No username or hostname, and an operator.
|
|
operID, _, _, err := env.db.CreateSession(
|
|
ctx, "oper", "", "", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
setUserModes(ctx, t, env.db, operID, userModes{oper: true})
|
|
|
|
_, err = env.svc.SetAway(ctx, bobID, "gone fishing")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
reply, err := env.svc.UserhostReply(ctx, []string{
|
|
"alice", "bob", "nobody", "oper",
|
|
}, "srv")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
want := "alice=+alice@localhost bob=-bob@localhost " +
|
|
"oper*=+oper@srv"
|
|
if reply != want {
|
|
t.Errorf("want %q, got %q", want, reply)
|
|
}
|
|
|
|
reply, err = env.svc.UserhostReply(
|
|
ctx, slices.Repeat([]string{"bob"}, 6), "srv",
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if strings.Count(reply, "bob=") != 5 {
|
|
t.Errorf("want 5 entries for 6 nicks, got %q", reply)
|
|
}
|
|
|
|
// A nick that cannot be looked up is an error, not a nick
|
|
// with no session.
|
|
cancelled, cancel := context.WithCancel(ctx)
|
|
cancel()
|
|
|
|
_, err = env.svc.UserhostReply(cancelled, []string{"alice"}, "srv")
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Errorf("want the lookup's error, got %v", err)
|
|
}
|
|
}
|
|
|
|
// userModes is the stored state of a session's user mode
|
|
// flags.
|
|
type userModes struct {
|
|
oper bool
|
|
wallops bool
|
|
}
|
|
|
|
// applyUserModeTest is one mode string, the flags stored
|
|
// before it is applied, and the expected outcome. An empty
|
|
// wantModes means the string is rejected with
|
|
// ERR_UMODEUNKNOWNFLAG, and then after must equal before.
|
|
type applyUserModeTest struct {
|
|
modeStr string
|
|
before userModes
|
|
wantModes string
|
|
after userModes
|
|
}
|
|
|
|
func applyUserModeTests() []applyUserModeTest {
|
|
none := userModes{}
|
|
oper := userModes{oper: true}
|
|
wallops := userModes{wallops: true}
|
|
both := userModes{oper: true, wallops: true}
|
|
|
|
return []applyUserModeTest{
|
|
{"+w", none, "+w", wallops},
|
|
{"-w", wallops, "+", none},
|
|
{"-o", oper, "+", none},
|
|
{"-wo", both, "+", none},
|
|
{"+w-o", oper, "+w", wallops},
|
|
{"-w+w", none, "+w", wallops},
|
|
{"+w-w", wallops, "+", none},
|
|
{"-w+o", both, "", both},
|
|
{"+o-w+w", both, "", both},
|
|
{"+wo", none, "", none},
|
|
{"+wz", none, "", none},
|
|
{"+z", none, "", none},
|
|
{"-x+y", none, "", none},
|
|
{"+y-x", none, "", none},
|
|
{"w", none, "", none},
|
|
{"xw", wallops, "", wallops},
|
|
{"", none, "", none},
|
|
{"+", none, "", none},
|
|
{"-", none, "", none},
|
|
{"+-+", none, "", none},
|
|
}
|
|
}
|
|
|
|
func TestApplyUserMode(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
|
|
for i, test := range applyUserModeTests() {
|
|
t.Run(test.modeStr, func(t *testing.T) {
|
|
ctx := t.Context()
|
|
sid := createSession(
|
|
ctx, t, env.db, fmt.Sprintf("user%d", i),
|
|
)
|
|
setUserModes(ctx, t, env.db, sid, test.before)
|
|
|
|
modes, err := env.svc.ApplyUserMode(
|
|
ctx, sid, test.modeStr,
|
|
)
|
|
checkApplyUserModeResult(t, test.wantModes, modes, err)
|
|
|
|
got := getUserModes(ctx, t, env.db, sid)
|
|
if got != test.after {
|
|
t.Errorf(
|
|
"stored modes: want %+v, got %+v",
|
|
test.after, got,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func checkApplyUserModeResult(
|
|
t *testing.T,
|
|
wantModes, modes string,
|
|
err error,
|
|
) {
|
|
t.Helper()
|
|
|
|
if wantModes == "" {
|
|
var ircErr *service.IRCError
|
|
if !errors.As(err, &ircErr) ||
|
|
ircErr.Code != irc.ErrUmodeUnknownFlag {
|
|
t.Fatalf("want ERR_UMODEUNKNOWNFLAG, got %v", err)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if modes != wantModes {
|
|
t.Errorf("modes: want %q, got %q", wantModes, modes)
|
|
}
|
|
}
|
|
|
|
func setUserModes(
|
|
ctx context.Context,
|
|
t *testing.T,
|
|
database *db.Database,
|
|
sid int64,
|
|
modes userModes,
|
|
) {
|
|
t.Helper()
|
|
|
|
err := database.SetSessionUserModes(
|
|
ctx, sid, &modes.wallops, &modes.oper,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("set user modes: %v", err)
|
|
}
|
|
}
|
|
|
|
func getUserModes(
|
|
ctx context.Context,
|
|
t *testing.T,
|
|
database *db.Database,
|
|
sid int64,
|
|
) userModes {
|
|
t.Helper()
|
|
|
|
oper, err := database.IsSessionOper(ctx, sid)
|
|
if err != nil {
|
|
t.Fatalf("read oper: %v", err)
|
|
}
|
|
|
|
wallops, err := database.IsSessionWallops(ctx, sid)
|
|
if err != nil {
|
|
t.Fatalf("read wallops: %v", err)
|
|
}
|
|
|
|
return userModes{oper: oper, wallops: wallops}
|
|
}
|