// Tests use a global viper instance for configuration, // making parallel execution unsafe. // //nolint:paralleltest package service_test import ( "context" "encoding/json" "errors" "fmt" "os" "testing" "git.eeqj.de/sneak/neoirc/internal/broker" "git.eeqj.de/sneak/neoirc/internal/config" "git.eeqj.de/sneak/neoirc/internal/db" "git.eeqj.de/sneak/neoirc/internal/globals" "git.eeqj.de/sneak/neoirc/internal/logger" "git.eeqj.de/sneak/neoirc/internal/service" "git.eeqj.de/sneak/neoirc/pkg/irc" "go.uber.org/fx" "go.uber.org/fx/fxtest" "golang.org/x/crypto/bcrypt" ) 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{ //nolint:exhaustruct Appname: "neoirc-test", Version: "test", } }, 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) } }