// 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" "sync" "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", 0, 0, 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) } } // concurrentJoinTrials is how many times each test of // concurrent JOINs repeats its race. Before a JOIN ran as // one transaction, ten trials lost each race every time. const concurrentJoinTrials = 10 // runConcurrently runs each of funcs in its own goroutine, // releasing them all at once, and returns their errors in // the order of funcs. func runConcurrently(funcs ...func() error) []error { start := make(chan struct{}) errs := make([]error, len(funcs)) var wg sync.WaitGroup for i, run := range funcs { wg.Add(1) go func() { defer wg.Done() <-start errs[i] = run() }() } close(start) wg.Wait() return errs } // joinConcurrently creates a session for each nick and has // them all JOIN channel at once. It returns the result and // error of each JOIN, in the order of nicks. func joinConcurrently( ctx context.Context, t *testing.T, env *testEnv, channel string, nicks ...string, ) ([]*service.JoinResult, []error) { t.Helper() results := make([]*service.JoinResult, len(nicks)) joins := make([]func() error, len(nicks)) for i, nick := range nicks { sessionID := createSession(ctx, t, env.db, nick) joins[i] = func() error { result, err := env.svc.JoinChannel( ctx, sessionID, nick, channel, "", ) results[i] = result return err } } return results, runConcurrently(joins...) } // TestConcurrentJoinsRespectUserLimit checks that of two // concurrent JOINs of a +l 2 channel with one member, one // gets in and the other gets ERR_CHANNELISFULL. func TestConcurrentJoinsRespectUserLimit(t *testing.T) { const userLimit = 2 env := newTestEnv(t) ctx := t.Context() for trial := range concurrentJoinTrials { channel := fmt.Sprintf("#full%d", trial) opNick := fmt.Sprintf("op%d", trial) opSID := createSession(ctx, t, env.db, opNick) created, err := env.svc.JoinChannel( ctx, opSID, opNick, channel, "", ) if err != nil { t.Fatalf("join %s: %v", opNick, err) } err = env.db.SetChannelUserLimit( ctx, created.ChannelID, userLimit, ) if err != nil { t.Fatalf("set +l: %v", err) } _, errs := joinConcurrently( ctx, t, env, channel, fmt.Sprintf("a%d", trial), fmt.Sprintf("b%d", trial), ) refused := 0 for _, joinErr := range errs { var ircErr *service.IRCError switch { case joinErr == nil: case errors.As(joinErr, &ircErr) && ircErr.Code == irc.ErrChannelIsFull: refused++ default: t.Errorf("trial %d: JOIN: %v", trial, joinErr) } } members, err := env.db.ChannelMembers(ctx, created.ChannelID) if err != nil { t.Fatalf("channel members: %v", err) } if len(members) != userLimit || refused != 1 { t.Errorf( "trial %d: %d members and %d refused, want %d and 1", trial, len(members), refused, userLimit, ) } } } // TestConcurrentFirstJoinsMakeOneOperator checks that of // two concurrent JOINs that create a channel, exactly one // makes its session the operator. func TestConcurrentFirstJoinsMakeOneOperator(t *testing.T) { env := newTestEnv(t) ctx := t.Context() for trial := range concurrentJoinTrials { results, errs := joinConcurrently( ctx, t, env, fmt.Sprintf("#new%d", trial), fmt.Sprintf("first%d", trial), fmt.Sprintf("second%d", trial), ) creators := 0 for i, err := range errs { if err != nil { t.Fatalf("trial %d: JOIN: %v", trial, err) } if results[i].IsCreator { creators++ } } members, err := env.db.ChannelMembers( ctx, results[0].ChannelID, ) if err != nil { t.Fatalf("channel members: %v", err) } operators := 0 for _, member := range members { if member.IsOperator { operators++ } } if creators != 1 || operators != 1 { t.Errorf( "trial %d: %d creators and %d operators, want 1 each", trial, creators, operators, ) } } } // TestJoinConcurrentWithLastPart checks that a JOIN // concurrent with the PART of a channel's only member // either joins a channel that still exists or gets // ERR_NOSUCHCHANNEL, and never fails otherwise. func TestJoinConcurrentWithLastPart(t *testing.T) { env := newTestEnv(t) ctx := t.Context() for trial := range concurrentJoinTrials { channel := fmt.Sprintf("#last%d", trial) leaverNick := fmt.Sprintf("leaver%d", trial) leaverSID := createSession(ctx, t, env.db, leaverNick) joinerNick := fmt.Sprintf("joiner%d", trial) joinerSID := createSession(ctx, t, env.db, joinerNick) _, err := env.svc.JoinChannel( ctx, leaverSID, leaverNick, channel, "", ) if err != nil { t.Fatalf("join %s: %v", leaverNick, err) } errs := runConcurrently( func() error { return env.svc.PartChannel( ctx, leaverSID, leaverNick, channel, "", ) }, func() error { _, joinErr := env.svc.JoinChannel( ctx, joinerSID, joinerNick, channel, "", ) return joinErr }, ) if errs[0] != nil { t.Fatalf("trial %d: PART: %v", trial, errs[0]) } var ircErr *service.IRCError switch { case errs[1] == nil: chID, lookupErr := env.db.GetChannelByName(ctx, channel) isMember, _ := env.db.IsChannelMember(ctx, chID, joinerSID) if lookupErr != nil || !isMember { t.Errorf( "trial %d: JOIN succeeded, but %s is not on %s", trial, joinerNick, channel, ) } case errors.As(errs[1], &ircErr) && ircErr.Code == irc.ErrNoSuchChannel: default: t.Errorf( "trial %d: JOIN got %v, want success or 403", trial, errs[1], ) } } } // TestJoinConcurrentWithLastQuit checks that a user who // sends JOIN without the key to a +k channel, again after // each ERR_BADCHANNELKEY, while the channel's only member // quits, gets in only once the channel is deleted, into a // new channel of that name with no key. func TestJoinConcurrentWithLastQuit(t *testing.T) { env := newTestEnv(t) ctx := t.Context() for trial := range concurrentJoinTrials { channel := fmt.Sprintf("#keyed%d", trial) leaverNick := fmt.Sprintf("leaver%d", trial) leaverSID := createSession(ctx, t, env.db, leaverNick) joinerNick := fmt.Sprintf("joiner%d", trial) joinerSID := createSession(ctx, t, env.db, joinerNick) created, err := env.svc.JoinChannel( ctx, leaverSID, leaverNick, channel, "", ) if err != nil { t.Fatalf("join %s: %v", leaverNick, err) } err = env.db.SetChannelKey(ctx, created.ChannelID, "secret") if err != nil { t.Fatalf("set +k: %v", err) } var joined *service.JoinResult errs := runConcurrently( func() error { env.svc.BroadcastQuit(ctx, leaverSID, leaverNick, "") return nil }, // The database has one connection, so retried // JOINs take turns with the QUIT's statements // and land between any two that are not in one // transaction. A JOIN after the QUIT gets in, so // the loop ends. func() error { for { var ( joinErr error ircErr *service.IRCError ) joined, joinErr = env.svc.JoinChannel( ctx, joinerSID, joinerNick, channel, "", ) if !errors.As(joinErr, &ircErr) || ircErr.Code != irc.ErrBadChannelKey { return joinErr } } }, ) if errs[1] != nil { t.Fatalf("trial %d: JOIN: %v", trial, errs[1]) } key, _ := env.db.GetChannelKey(ctx, joined.ChannelID) if key != "" { t.Errorf( "trial %d: %s got into +k %s without the key", trial, joinerNick, channel, ) } } } // TestJoinUsesUpInvite checks that the JOIN an invite let // into a +i channel uses the invite up, so that a JOIN // after leaving gets ERR_INVITEONLYCHAN. func TestJoinUsesUpInvite(t *testing.T) { const channel = "#club" env := newTestEnv(t) ctx := t.Context() opSID := createSession(ctx, t, env.db, "op") guestSID := createSession(ctx, t, env.db, "guest") created, err := env.svc.JoinChannel(ctx, opSID, "op", channel, "") if err != nil { t.Fatalf("join op: %v", err) } err = env.db.SetChannelInviteOnly(ctx, created.ChannelID, true) if err != nil { t.Fatalf("set +i: %v", err) } _, err = env.svc.InviteUser(ctx, opSID, "op", "guest", channel) if err != nil { t.Fatalf("invite guest: %v", err) } _, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "") if err != nil { t.Fatalf("invited join: %v", err) } err = env.svc.PartChannel(ctx, guestSID, "guest", channel, "") if err != nil { t.Fatalf("part guest: %v", err) } _, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "") var ircErr *service.IRCError if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrInviteOnlyChan { t.Fatalf("second join: want ERR_INVITEONLYCHAN, got %v", err) } } // TestJoinRefusedByHostmaskBan checks that a ban naming a // session's nick, username and hostname refuses its JOIN // with ERR_BANNEDFROMCHAN. func TestJoinRefusedByHostmaskBan(t *testing.T) { const channel = "#club" env := newTestEnv(t) ctx := t.Context() opSID := createSession(ctx, t, env.db, "op") created, err := env.svc.JoinChannel(ctx, opSID, "op", channel, "") if err != nil { t.Fatalf("join op: %v", err) } err = env.db.AddChannelBan( ctx, created.ChannelID, "guest!user@host.example", "op", ) if err != nil { t.Fatalf("add ban: %v", err) } guestSID, _, _, err := env.db.CreateSession( ctx, "guest", "user", "host.example", "127.0.0.1", ) if err != nil { t.Fatalf("create session guest: %v", err) } _, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "") var ircErr *service.IRCError if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrBannedFromChan { t.Fatalf("want ERR_BANNEDFROMCHAN, got %v", err) } } // TestPartDeletesChannelOnlyWhenEmpty checks that a PART // keeps a channel other members are still on and deletes // the channel when its last member parts. func TestPartDeletesChannelOnlyWhenEmpty(t *testing.T) { const channel = "#room" env := newTestEnv(t) ctx := t.Context() aliceSID := createSession(ctx, t, env.db, "alice") bobSID := createSession(ctx, t, env.db, "bob") _, err := env.svc.JoinChannel(ctx, aliceSID, "alice", channel, "") if err != nil { t.Fatalf("join alice: %v", err) } _, err = env.svc.JoinChannel(ctx, bobSID, "bob", channel, "") if err != nil { t.Fatalf("join bob: %v", err) } err = env.svc.PartChannel(ctx, aliceSID, "alice", channel, "") if err != nil { t.Fatalf("part alice: %v", err) } chID, err := env.db.GetChannelByName(ctx, channel) if err != nil { t.Fatalf("channel gone while bob is on it: %v", err) } isMember, _ := env.db.IsChannelMember(ctx, chID, bobSID) if !isMember { t.Fatal("bob is not on the channel after alice parted") } err = env.svc.PartChannel(ctx, bobSID, "bob", channel, "") if err != nil { t.Fatalf("part bob: %v", err) } _, err = env.db.GetChannelByName(ctx, channel) if err == nil { t.Fatal("channel still exists after its last member parted") } } // TestKickOfLastMemberDeletesChannel checks that a KICK // that leaves a channel with no members deletes it. func TestKickOfLastMemberDeletesChannel(t *testing.T) { const channel = "#room" env := newTestEnv(t) ctx := t.Context() sid := createSession(ctx, t, env.db, "alice") _, err := env.svc.JoinChannel(ctx, sid, "alice", channel, "") if err != nil { t.Fatalf("join alice: %v", err) } err = env.svc.KickUser(ctx, sid, "alice", channel, "alice", "") if err != nil { t.Fatalf("kick alice: %v", err) } _, err = env.db.GetChannelByName(ctx, channel) if err == nil { t.Fatal("channel still exists after its last member was kicked") } } 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") } } // TestSendDirectMessageTargetQuits checks that a DM whose // target quits after its nick is looked up gets the same // error as a DM to an unknown nick. The trigger deletes the // target's session just before the DM is stored. func TestSendDirectMessageTargetQuits(t *testing.T) { env := newTestEnv(t) ctx := t.Context() sid := createSession(ctx, t, env.db, "alice") createSession(ctx, t, env.db, "bob") _, err := env.db.GetDB().ExecContext(ctx, `CREATE TRIGGER delete_recipient BEFORE INSERT ON messages BEGIN DELETE FROM sessions WHERE id = NEW.to_session_id; END`) if err != nil { t.Fatalf("create trigger: %v", err) } body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson _, err = env.svc.SendDirectMessage( ctx, sid, "alice", irc.CmdPrivmsg, "bob", body, nil, ) var ircErr *service.IRCError if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrNoSuchNick { t.Fatalf("want ERR_NOSUCHNICK, got %v", err) } } // TestSendChannelMessageChannelDeleted checks that a channel // message whose channel is deleted after it is looked up gets // the same error as a message to an unknown channel. The // trigger deletes the channel just before the message is // stored. func TestSendChannelMessageChannelDeleted(t *testing.T) { env := newTestEnv(t) ctx := t.Context() sid := createSession(ctx, t, env.db, "alice") _, err := env.svc.JoinChannel(ctx, sid, "alice", "#gone", "") if err != nil { t.Fatalf("join alice: %v", err) } _, err = env.db.GetDB().ExecContext(ctx, `CREATE TRIGGER delete_recipient BEFORE INSERT ON messages BEGIN DELETE FROM channels WHERE id = NEW.to_channel_id; END`) if err != nil { t.Fatalf("create trigger: %v", err) } body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson _, _, err = env.svc.SendChannelMessage( ctx, sid, "alice", irc.CmdPrivmsg, "#gone", body, nil, ) var ircErr *service.IRCError if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrNoSuchChannel { t.Fatalf("want ERR_NOSUCHCHANNEL, got %v", err) } } 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} }