Files
neoirc/internal/service/service_test.go
T
clawbot 4ab320ab2d
check / check (push) Waiting to run
Give each handler and service test its own database (closes #131)
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>
2026-10-20 13:04:40 +02:00

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}
}