// 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" "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{ //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) } } func TestQueryUserMode(t *testing.T) { env := newTestEnv(t) ctx := t.Context() sid := createSession(ctx, t, env.db, "alice") // Fresh session has no modes. modes := env.svc.QueryUserMode(ctx, sid) if modes != "+" { t.Errorf("expected +, got %s", modes) } // Set wallops. _ = env.db.SetSessionWallops(ctx, sid, true) modes = env.svc.QueryUserMode(ctx, sid) if modes != "+w" { t.Errorf("expected +w, got %s", modes) } // Set oper. _ = env.db.SetSessionOper(ctx, sid, true) modes = env.svc.QueryUserMode(ctx, sid) if modes != "+ow" { t.Errorf("expected +ow, got %s", modes) } } // TestApplyUserMode is the rigorous table-driven suite for // the shared user-mode parser. It covers every case listed // in sneak's review of PR #96 plus a few adjacent ones. // Each case asserts the resulting mode string AND the // persisted session state, to prove that rejected input // leaves no side effects. func TestApplyUserMode(t *testing.T) { type caseState struct { oper bool wallops bool } // Each case starts from initialState, calls // ApplyUserMode(modeStr), and verifies either the // returned mode string (wantModes) or an IRCError with // wantErrCode. After the call, the DB must match // wantState — proving that rejected input made no // change. cases := []struct { name string initialState caseState modeStr string wantModes string wantErr bool wantErrCode irc.IRCMessageType wantState caseState }{ // Happy path: single-char operations. { name: "+w from empty", modeStr: "+w", wantModes: "+w", wantState: caseState{wallops: true}, }, { name: "-w from +w", initialState: caseState{wallops: true}, modeStr: "-w", wantModes: "+", wantState: caseState{}, }, { name: "-o from +o", initialState: caseState{oper: true}, modeStr: "-o", wantModes: "+", wantState: caseState{}, }, // Multi-char without sign transitions. { name: "-wo from +ow", initialState: caseState{oper: true, wallops: true}, modeStr: "-wo", wantModes: "+", wantState: caseState{}, }, // Multi-char with sign transitions. { name: "+w-o from +o", initialState: caseState{oper: true}, modeStr: "+w-o", wantModes: "+w", wantState: caseState{wallops: true}, }, { name: "-w+o always rejects +o", initialState: caseState{wallops: true, oper: true}, modeStr: "-w+o", wantErr: true, wantErrCode: irc.ErrUmodeUnknownFlag, // +o is rejected before any op applies; wallops // stays set. wantState: caseState{wallops: true, oper: true}, }, { name: "+o-w+w rejects because of +o", initialState: caseState{wallops: true, oper: true}, modeStr: "+o-w+w", wantErr: true, wantErrCode: irc.ErrUmodeUnknownFlag, // Wallops must NOT be cleared; oper must NOT be // cleared. Rejection is fully atomic. wantState: caseState{wallops: true, oper: true}, }, { name: "-x+y rejects unknown -x", modeStr: "-x+y", wantErr: true, wantErrCode: irc.ErrUmodeUnknownFlag, wantState: caseState{}, }, { name: "+y-x rejects unknown +y", modeStr: "+y-x", wantErr: true, wantErrCode: irc.ErrUmodeUnknownFlag, wantState: caseState{}, }, // Malformed prefix — must not silently treat as '-'. { name: "w no prefix rejects", modeStr: "w", wantErr: true, wantErrCode: irc.ErrUmodeUnknownFlag, wantState: caseState{}, }, { name: "xw no prefix rejects (would have been" + " silently -w before)", initialState: caseState{wallops: true}, modeStr: "xw", wantErr: true, wantErrCode: irc.ErrUmodeUnknownFlag, // Prove wallops is NOT cleared — the whole point // of sneak's review. wantState: caseState{wallops: true}, }, { name: "empty string rejects", modeStr: "", wantErr: true, wantErrCode: irc.ErrUmodeUnknownFlag, wantState: caseState{}, }, // Bare signs with no mode letters reject. { name: "bare + rejects", modeStr: "+", wantErr: true, wantErrCode: irc.ErrUmodeUnknownFlag, wantState: caseState{}, }, { name: "bare - rejects", modeStr: "-", wantErr: true, wantErrCode: irc.ErrUmodeUnknownFlag, wantState: caseState{}, }, { name: "+-+ rejects (no mode letters)", modeStr: "+-+", wantErr: true, wantErrCode: irc.ErrUmodeUnknownFlag, wantState: caseState{}, }, // Unknown mode letters reject whole string. { name: "+z unknown mode rejects", modeStr: "+z", wantErr: true, wantErrCode: irc.ErrUmodeUnknownFlag, wantState: caseState{}, }, { name: "+wz rejects whole thing; +w side effect" + " must NOT persist", modeStr: "+wz", wantErr: true, wantErrCode: irc.ErrUmodeUnknownFlag, // Wallops must NOT be set. wantState: caseState{}, }, { name: "+wo rejects whole thing; +w side effect" + " must NOT persist", modeStr: "+wo", wantErr: true, wantErrCode: irc.ErrUmodeUnknownFlag, wantState: caseState{}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { env := newTestEnv(t) ctx := t.Context() sid := createSession(ctx, t, env.db, "alice") if tc.initialState.oper { if err := env.db.SetSessionOper( ctx, sid, true, ); err != nil { t.Fatalf("init oper: %v", err) } } if tc.initialState.wallops { if err := env.db.SetSessionWallops( ctx, sid, true, ); err != nil { t.Fatalf("init wallops: %v", err) } } result, err := env.svc.ApplyUserMode( ctx, sid, tc.modeStr, ) if tc.wantErr { var ircErr *service.IRCError if !errors.As(err, &ircErr) { t.Fatalf( "expected IRCError, got %v", err, ) } if ircErr.Code != tc.wantErrCode { t.Errorf( "code: want %d got %d", tc.wantErrCode, ircErr.Code, ) } } else { if err != nil { t.Fatalf("unexpected error: %v", err) } if result != tc.wantModes { t.Errorf( "modes: want %q got %q", tc.wantModes, result, ) } } // Whether or not the call errored, the persisted // state must match wantState — this is the // atomicity guarantee sneak demanded. gotOper, err := env.db.IsSessionOper(ctx, sid) if err != nil { t.Fatalf("read oper: %v", err) } gotWallops, err := env.db.IsSessionWallops( ctx, sid, ) if err != nil { t.Fatalf("read wallops: %v", err) } if gotOper != tc.wantState.oper { t.Errorf( "oper: want %v got %v", tc.wantState.oper, gotOper, ) } if gotWallops != tc.wantState.wallops { t.Errorf( "wallops: want %v got %v", tc.wantState.wallops, gotWallops, ) } }) } }