All checks were successful
check / check (push) Successful in 56s
- server.go: drop unused (*Server).serve int return (unparam) and remove the dead exitCode field so cleanShutdown no longer writes to a field nothing reads. - service.go: rename range var ch -> modeChar in parseUserModeString and the isKnownUserModeChar parameter (varnamelen). - service_test.go: rename tc -> testCase (varnamelen); lift the inline struct and caseState to package-level named types (applyUserModeCase, applyUserModeCaseState) with every field set explicitly (exhaustruct); split the 167-line case table into four categorised helpers (funlen); extract the per-case runner and outcome/state verifiers into helpers so TestApplyUserMode drops below gocognit 30 and flattens the wantErr nestif block. No changes to .golangci.yml, Makefile, Dockerfile, or CI config. No //nolint was used to silence any of these findings. docker build --no-cache . passes clean: 0 lint issues, all tests pass with -race, binary compiles.
801 lines
19 KiB
Go
801 lines
19 KiB
Go
// 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)
|
|
}
|
|
}
|
|
|
|
// applyUserModeCaseState is the subset of session user-mode
|
|
// state the rigorous TestApplyUserMode suite asserts on. It
|
|
// mirrors the columns (oper, wallops) that the parser is
|
|
// permitted to mutate.
|
|
type applyUserModeCaseState struct {
|
|
oper bool
|
|
wallops bool
|
|
}
|
|
|
|
// applyUserModeCase describes one rigor-suite case for
|
|
// Service.ApplyUserMode: the pre-call DB state, the mode
|
|
// string input, and the expected post-call observable state
|
|
// (mode string on success, IRC error code on rejection, and
|
|
// persisted session state either way).
|
|
type applyUserModeCase struct {
|
|
name string
|
|
initialState applyUserModeCaseState
|
|
modeStr string
|
|
wantModes string
|
|
wantErr bool
|
|
wantErrCode irc.IRCMessageType
|
|
wantState applyUserModeCaseState
|
|
}
|
|
|
|
// applyUserModeCases returns every case listed in sneak's
|
|
// review of PR #96 plus a few adjacent ones. Split across
|
|
// helpers by category so each stays under funlen.
|
|
func applyUserModeCases() []applyUserModeCase {
|
|
cases := applyUserModeHappyPathCases()
|
|
cases = append(cases, applyUserModeSignTransitionCases()...)
|
|
cases = append(cases, applyUserModeMalformedCases()...)
|
|
cases = append(cases, applyUserModeUnknownLetterCases()...)
|
|
|
|
return cases
|
|
}
|
|
|
|
// applyUserModeHappyPathCases covers valid single-char and
|
|
// multi-char-without-sign-transition mode operations.
|
|
func applyUserModeHappyPathCases() []applyUserModeCase {
|
|
return []applyUserModeCase{
|
|
{
|
|
name: "+w from empty",
|
|
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
|
modeStr: "+w",
|
|
wantModes: "+w",
|
|
wantErr: false,
|
|
wantErrCode: 0,
|
|
wantState: applyUserModeCaseState{oper: false, wallops: true},
|
|
},
|
|
{
|
|
name: "-w from +w",
|
|
initialState: applyUserModeCaseState{oper: false, wallops: true},
|
|
modeStr: "-w",
|
|
wantModes: "+",
|
|
wantErr: false,
|
|
wantErrCode: 0,
|
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
|
},
|
|
{
|
|
name: "-o from +o",
|
|
initialState: applyUserModeCaseState{oper: true, wallops: false},
|
|
modeStr: "-o",
|
|
wantModes: "+",
|
|
wantErr: false,
|
|
wantErrCode: 0,
|
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
|
},
|
|
{
|
|
name: "-wo from +ow",
|
|
initialState: applyUserModeCaseState{oper: true, wallops: true},
|
|
modeStr: "-wo",
|
|
wantModes: "+",
|
|
wantErr: false,
|
|
wantErrCode: 0,
|
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
|
},
|
|
}
|
|
}
|
|
|
|
// applyUserModeSignTransitionCases covers multi-char mode
|
|
// strings where '+' and '-' flip partway through. +o is
|
|
// never legal via MODE, so strings containing it must be
|
|
// rejected atomically.
|
|
func applyUserModeSignTransitionCases() []applyUserModeCase {
|
|
return []applyUserModeCase{
|
|
{
|
|
name: "+w-o from +o",
|
|
initialState: applyUserModeCaseState{oper: true, wallops: false},
|
|
modeStr: "+w-o",
|
|
wantModes: "+w",
|
|
wantErr: false,
|
|
wantErrCode: 0,
|
|
wantState: applyUserModeCaseState{oper: false, wallops: true},
|
|
},
|
|
{
|
|
// +o is rejected before any op applies; wallops
|
|
// stays set.
|
|
name: "-w+o always rejects +o",
|
|
initialState: applyUserModeCaseState{oper: true, wallops: true},
|
|
modeStr: "-w+o",
|
|
wantModes: "",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
wantState: applyUserModeCaseState{oper: true, wallops: true},
|
|
},
|
|
{
|
|
// Wallops must NOT be cleared; oper must NOT be
|
|
// cleared. Rejection is fully atomic.
|
|
name: "+o-w+w rejects because of +o",
|
|
initialState: applyUserModeCaseState{oper: true, wallops: true},
|
|
modeStr: "+o-w+w",
|
|
wantModes: "",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
wantState: applyUserModeCaseState{oper: true, wallops: true},
|
|
},
|
|
}
|
|
}
|
|
|
|
// applyUserModeMalformedCases covers inputs that lack a
|
|
// leading '+' or '-' and inputs that consist of bare signs
|
|
// without mode letters. All must be rejected with no side
|
|
// effects.
|
|
func applyUserModeMalformedCases() []applyUserModeCase {
|
|
return []applyUserModeCase{
|
|
{
|
|
name: "w no prefix rejects",
|
|
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
|
modeStr: "w",
|
|
wantModes: "",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
|
},
|
|
{
|
|
// Prove wallops is NOT cleared — the whole point
|
|
// of sneak's review.
|
|
name: "xw no prefix rejects (would have been" +
|
|
" silently -w before)",
|
|
initialState: applyUserModeCaseState{oper: false, wallops: true},
|
|
modeStr: "xw",
|
|
wantModes: "",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
wantState: applyUserModeCaseState{oper: false, wallops: true},
|
|
},
|
|
{
|
|
name: "empty string rejects",
|
|
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
|
modeStr: "",
|
|
wantModes: "",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
|
},
|
|
{
|
|
name: "bare + rejects",
|
|
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
|
modeStr: "+",
|
|
wantModes: "",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
|
},
|
|
{
|
|
name: "bare - rejects",
|
|
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
|
modeStr: "-",
|
|
wantModes: "",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
|
},
|
|
{
|
|
name: "+-+ rejects (no mode letters)",
|
|
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
|
modeStr: "+-+",
|
|
wantModes: "",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
|
},
|
|
}
|
|
}
|
|
|
|
// applyUserModeUnknownLetterCases covers well-formed prefix
|
|
// strings that contain unknown mode letters. Rejection must
|
|
// be atomic: any valid letters before the invalid one must
|
|
// not persist.
|
|
func applyUserModeUnknownLetterCases() []applyUserModeCase {
|
|
return []applyUserModeCase{
|
|
{
|
|
name: "-x+y rejects unknown -x",
|
|
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
|
modeStr: "-x+y",
|
|
wantModes: "",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
|
},
|
|
{
|
|
name: "+y-x rejects unknown +y",
|
|
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
|
modeStr: "+y-x",
|
|
wantModes: "",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
|
},
|
|
{
|
|
name: "+z unknown mode rejects",
|
|
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
|
modeStr: "+z",
|
|
wantModes: "",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
|
},
|
|
{
|
|
// Wallops must NOT be set.
|
|
name: "+wz rejects whole thing; +w side effect" +
|
|
" must NOT persist",
|
|
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
|
modeStr: "+wz",
|
|
wantModes: "",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
|
},
|
|
{
|
|
name: "+wo rejects whole thing; +w side effect" +
|
|
" must NOT persist",
|
|
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
|
modeStr: "+wo",
|
|
wantModes: "",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
for _, testCase := range applyUserModeCases() {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
runApplyUserModeCase(t, testCase)
|
|
})
|
|
}
|
|
}
|
|
|
|
// runApplyUserModeCase executes one applyUserModeCase: seed
|
|
// the session state, invoke ApplyUserMode, and verify both
|
|
// the returned value and the post-call persisted state.
|
|
func runApplyUserModeCase(
|
|
t *testing.T, testCase applyUserModeCase,
|
|
) {
|
|
t.Helper()
|
|
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
sid := createSession(ctx, t, env.db, "alice")
|
|
|
|
seedApplyUserModeState(ctx, t, env.db, sid, testCase.initialState)
|
|
|
|
result, err := env.svc.ApplyUserMode(
|
|
ctx, sid, testCase.modeStr,
|
|
)
|
|
|
|
verifyApplyUserModeOutcome(t, testCase, result, err)
|
|
verifyApplyUserModeState(ctx, t, env.db, sid, testCase.wantState)
|
|
}
|
|
|
|
// seedApplyUserModeState installs the pre-call session
|
|
// state described by initialState.
|
|
func seedApplyUserModeState(
|
|
ctx context.Context,
|
|
t *testing.T,
|
|
database *db.Database,
|
|
sid int64,
|
|
initialState applyUserModeCaseState,
|
|
) {
|
|
t.Helper()
|
|
|
|
if initialState.oper {
|
|
if err := database.SetSessionOper(
|
|
ctx, sid, true,
|
|
); err != nil {
|
|
t.Fatalf("init oper: %v", err)
|
|
}
|
|
}
|
|
|
|
if initialState.wallops {
|
|
if err := database.SetSessionWallops(
|
|
ctx, sid, true,
|
|
); err != nil {
|
|
t.Fatalf("init wallops: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// verifyApplyUserModeOutcome asserts the direct return
|
|
// value of ApplyUserMode. It dispatches to the error- or
|
|
// success-specific verifier based on wantErr.
|
|
func verifyApplyUserModeOutcome(
|
|
t *testing.T,
|
|
testCase applyUserModeCase,
|
|
result string,
|
|
err error,
|
|
) {
|
|
t.Helper()
|
|
|
|
if testCase.wantErr {
|
|
verifyApplyUserModeError(t, testCase, err)
|
|
|
|
return
|
|
}
|
|
|
|
verifyApplyUserModeSuccess(t, testCase, result, err)
|
|
}
|
|
|
|
// verifyApplyUserModeError checks that err is a
|
|
// *service.IRCError whose code matches wantErrCode.
|
|
func verifyApplyUserModeError(
|
|
t *testing.T, testCase applyUserModeCase, err error,
|
|
) {
|
|
t.Helper()
|
|
|
|
var ircErr *service.IRCError
|
|
if !errors.As(err, &ircErr) {
|
|
t.Fatalf("expected IRCError, got %v", err)
|
|
}
|
|
|
|
if ircErr.Code != testCase.wantErrCode {
|
|
t.Errorf(
|
|
"code: want %d got %d",
|
|
testCase.wantErrCode, ircErr.Code,
|
|
)
|
|
}
|
|
}
|
|
|
|
// verifyApplyUserModeSuccess checks that err is nil and the
|
|
// returned mode string matches wantModes.
|
|
func verifyApplyUserModeSuccess(
|
|
t *testing.T,
|
|
testCase applyUserModeCase,
|
|
result string,
|
|
err error,
|
|
) {
|
|
t.Helper()
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if result != testCase.wantModes {
|
|
t.Errorf(
|
|
"modes: want %q got %q",
|
|
testCase.wantModes, result,
|
|
)
|
|
}
|
|
}
|
|
|
|
// verifyApplyUserModeState asserts the post-call persisted
|
|
// session state. This is the atomicity guarantee sneak
|
|
// demanded: whether the call succeeded or was rejected, the
|
|
// DB must match wantState exactly.
|
|
func verifyApplyUserModeState(
|
|
ctx context.Context,
|
|
t *testing.T,
|
|
database *db.Database,
|
|
sid int64,
|
|
wantState applyUserModeCaseState,
|
|
) {
|
|
t.Helper()
|
|
|
|
gotOper, err := database.IsSessionOper(ctx, sid)
|
|
if err != nil {
|
|
t.Fatalf("read oper: %v", err)
|
|
}
|
|
|
|
gotWallops, err := database.IsSessionWallops(ctx, sid)
|
|
if err != nil {
|
|
t.Fatalf("read wallops: %v", err)
|
|
}
|
|
|
|
if gotOper != wantState.oper {
|
|
t.Errorf(
|
|
"oper: want %v got %v",
|
|
wantState.oper, gotOper,
|
|
)
|
|
}
|
|
|
|
if gotWallops != wantState.wallops {
|
|
t.Errorf(
|
|
"wallops: want %v got %v",
|
|
wantState.wallops, gotWallops,
|
|
)
|
|
}
|
|
}
|