// Tests use a global viper instance for configuration, // making parallel execution unsafe. // //nolint:paralleltest package service_test import ( "context" "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() dbURL := fmt.Sprintf( "file:svc_test_%p?mode=memory&cache=shared", t, ) 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 } 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} }